From ced6eff98e9d718057495e660106891ff39f7406 Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Fri, 22 May 2020 01:25:05 -0600 Subject: [PATCH 01/41] Started moving settings into popovers --- sublime/app.py | 6 + sublime/config.py | 30 ++-- sublime/ui/app_styles.css | 14 +- sublime/ui/common/__init__.py | 3 +- sublime/ui/common/icon_button.py | 52 ++++++- sublime/ui/main.py | 240 ++++++++++++++++++++++++++----- 6 files changed, 294 insertions(+), 51 deletions(-) diff --git a/sublime/app.py b/sublime/app.py index 5f8a9f1..19a7cd2 100644 --- a/sublime/app.py +++ b/sublime/app.py @@ -490,6 +490,12 @@ class SublimeMusicApp(Gtk.Application): def on_refresh_window( self, _, state_updates: Dict[str, Any], force: bool = False, ): + if settings := state_updates.get("__settings__"): + for k, v in settings.items(): + print('SET', k, v) + setattr(self.app_config, k, v) + del state_updates["__settings__"] + for k, v in state_updates.items(): setattr(self.app_config.state, k, v) self.update_window(force=force) diff --git a/sublime/config.py b/sublime/config.py index 8eb1868..e18bbe0 100644 --- a/sublime/config.py +++ b/sublime/config.py @@ -72,29 +72,39 @@ class ServerConfiguration: @dataclass class AppConfiguration: + version: int = 3 + cache_location: str = "" + filename: Optional[Path] = None + + # Servers servers: List[ServerConfiguration] = field(default_factory=list) current_server_index: int = -1 - cache_location: str = "" + + # Global Settings + song_play_notification: bool = True + offline_mode: bool = False + serve_over_lan: bool = True + + # TODO this should probably be moved to the cache adapter settings max_cache_size_mb: int = -1 # -1 means unlimited always_stream: bool = False # always stream instead of downloading songs download_on_stream: bool = True # also download when streaming a song - song_play_notification: bool = True prefetch_amount: int = 3 concurrent_download_limit: int = 5 port_number: int = 8282 - version: int = 3 - serve_over_lan: bool = True replay_gain: ReplayGainType = ReplayGainType.NO - filename: Optional[Path] = None @staticmethod def load_from_file(filename: Path) -> "AppConfiguration": args = {} - if filename.exists(): - with open(filename, "r") as f: - field_names = {f.name for f in fields(AppConfiguration)} - args = yaml.load(f, Loader=yaml.CLoader).items() - args = dict(filter(lambda kv: kv[0] in field_names, args)) + try: + if filename.exists(): + with open(filename, "r") as f: + field_names = {f.name for f in fields(AppConfiguration)} + args = yaml.load(f, Loader=yaml.CLoader).items() + args = dict(filter(lambda kv: kv[0] in field_names, args)) + except Exception: + pass config = AppConfiguration(**args) config.filename = filename diff --git a/sublime/ui/app_styles.css b/sublime/ui/app_styles.css index 3403203..c65101e 100644 --- a/sublime/ui/app_styles.css +++ b/sublime/ui/app_styles.css @@ -1,7 +1,10 @@ /* ********** Main ********** */ -#connected-to-label { - margin-top: 10px; - margin-bottom: 10px; +#server-connection-icon { /* TODO remove */ + /* box-shadow: 0px 0px 3px green; */ +} + +#main-menu-box { + min-width: 230px; } #icon-button-box image { @@ -14,6 +17,11 @@ margin-right: 3px; } +#menu-item-download-settings, +#menu-item-clear-cache { + min-width: 230px; +} + /* ********** Playlist ********** */ #playlist-list-listbox row { margin: 0; diff --git a/sublime/ui/common/__init__.py b/sublime/ui/common/__init__.py index dc2f0d8..6922954 100644 --- a/sublime/ui/common/__init__.py +++ b/sublime/ui/common/__init__.py @@ -1,6 +1,6 @@ from .album_with_songs import AlbumWithSongs from .edit_form_dialog import EditFormDialog -from .icon_button import IconButton, IconToggleButton +from .icon_button import IconButton, IconMenuButton, IconToggleButton from .song_list_column import SongListColumn from .spinner_image import SpinnerImage @@ -8,6 +8,7 @@ __all__ = ( "AlbumWithSongs", "EditFormDialog", "IconButton", + "IconMenuButton", "IconToggleButton", "SongListColumn", "SpinnerImage", diff --git a/sublime/ui/common/icon_button.py b/sublime/ui/common/icon_button.py index f8ad0b1..87698fc 100644 --- a/sublime/ui/common/icon_button.py +++ b/sublime/ui/common/icon_button.py @@ -1,6 +1,6 @@ -from typing import Optional +from typing import Any, Optional -from gi.repository import Gtk +from gi.repository import GdkPixbuf, Gtk class IconButton(Gtk.Button): @@ -70,3 +70,51 @@ class IconToggleButton(Gtk.ToggleButton): def set_active(self, active: bool): super().set_active(active) + + +class IconMenuButton(Gtk.MenuButton): + def __init__( + self, + icon_name: Optional[str] = None, + icon_image_filename: Optional[str] = None, + tooltip_text: str = "", + relief: bool = False, + icon_size: Gtk.IconSize = Gtk.IconSize.BUTTON, + label: str = None, + popover: Any = None, + **kwargs, + ): + Gtk.MenuButton.__init__(self, **kwargs) + + if popover: + self.set_use_popover(True) + self.set_popover(popover) + + self.icon_size = icon_size + box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, name="icon-button-box") + + self.image = Gtk.Image() + if icon_name: + self.image.set_from_icon_name(icon_name, self.icon_size) + box.add(self.image) + elif icon_image_filename: + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( + icon_image_filename, -1, 16, True, + ) + self.image.set_from_pixbuf(pixbuf) + box.add(self.image) + + if label is not None: + box.add(Gtk.Label(label=label)) + + if not relief: + self.props.relief = Gtk.ReliefStyle.NONE + + self.add(box) + self.set_tooltip_text(tooltip_text) + + def set_icon(self, icon_name: Optional[str]): + self.image.set_from_icon_name(icon_name, self.icon_size) + + def set_from_file(self, icon_file: Optional[str]): + self.image.set_from_file(icon_file) diff --git a/sublime/ui/main.py b/sublime/ui/main.py index 4e51f88..4a423e0 100644 --- a/sublime/ui/main.py +++ b/sublime/ui/main.py @@ -1,12 +1,12 @@ from functools import partial -from typing import Any, Optional, Set +from typing import Any, Callable, Optional, Set from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango from sublime.adapters import AdapterManager, api_objects as API, Result -from sublime.config import AppConfiguration +from sublime.config import AppConfiguration, ReplayGainType from sublime.ui import albums, artists, browse, player_controls, playlists, util -from sublime.ui.common import IconButton, SpinnerImage +from sublime.ui.common import IconButton, IconMenuButton, SpinnerImage class MainWindow(Gtk.ApplicationWindow): @@ -28,6 +28,8 @@ class MainWindow(Gtk.ApplicationWindow): "go-to": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (str, str),), } + _updating_settings: bool = False + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.set_default_size(1150, 768) @@ -109,14 +111,24 @@ class MainWindow(Gtk.ApplicationWindow): self.notification_revealer.set_reveal_child(False) # Update the Connected to label on the popup menu. - if app_config.server: - self.connected_to_label.set_markup( - f"Connected to {app_config.server.name}" - ) - else: - self.connected_to_label.set_markup( - 'Not Connected to a Server' - ) + # if app_config.server: + # self.connected_to_label.set_markup( + # f"Connected to {app_config.server.name}" + # ) + # else: + # self.connected_to_label.set_markup( + # 'Not Connected to a Server' + # ) + + self._updating_settings = True + + self.offline_mode_switch.set_active(app_config.offline_mode) + self.notification_switch.set_active(app_config.song_play_notification) + self.replay_gain_options.set_active_id(app_config.replay_gain.as_string()) + self.serve_over_lan_switch.set_active(app_config.serve_over_lan) + self.port_number_entry.set_value(app_config.port_number) + + self._updating_settings = False self.stack.set_visible_child_name(app_config.state.current_tab) @@ -164,19 +176,47 @@ class MainWindow(Gtk.ApplicationWindow): switcher = Gtk.StackSwitcher(stack=stack) header.set_custom_title(switcher) + button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) + + # Downloads + self.downloads_popover = self._create_downloads_popover() + self.downloads_menu_button = IconMenuButton( + "folder-download-symbolic", + tooltip_text="Show download status", + popover=self.downloads_popover, + ) + self.downloads_menu_button.connect("clicked", self._on_downloads_menu_clicked) + self.downloads_popover.set_relative_to(self.downloads_menu_button) + button_box.add(self.downloads_menu_button) + # Menu button - menu_button = Gtk.MenuButton() - menu_button.set_tooltip_text("Open application menu") - menu_button.set_use_popover(True) - menu_button.set_popover(self._create_menu()) - menu_button.connect("clicked", self._on_menu_clicked) - self.menu.set_relative_to(menu_button) + self.main_menu_popover = self._create_main_menu() + main_menu_button = IconMenuButton( + "emblem-system-symbolic", + tooltip_text="Open Sublime Music settings", + popover=self.main_menu_popover, + ) + main_menu_button.connect("clicked", self._on_main_menu_clicked) + self.main_menu_popover.set_relative_to(main_menu_button) + button_box.add(main_menu_button) - icon = Gio.ThemedIcon(name="open-menu-symbolic") - image = Gtk.Image.new_from_gicon(icon, Gtk.IconSize.BUTTON) - menu_button.add(image) + # Server icon and change server dropdown + self.server_connection_popover = self._create_server_connection_popover() + self.server_connection_menu_button = IconMenuButton( + name="server-connection-icon", + icon_image_filename="/home/sumner/tmp/server-subsonic-symbolic.svg", + tooltip_text="Server connection settings", + popover=self.server_connection_popover, + ) + self.server_connection_menu_button.connect( + "clicked", self._on_server_connection_menu_clicked + ) + self.server_connection_popover.set_relative_to( + self.server_connection_menu_button + ) + button_box.add(self.server_connection_menu_button) - header.pack_end(menu_button) + header.pack_end(button_box) return header @@ -192,29 +232,141 @@ class MainWindow(Gtk.ApplicationWindow): label.get_style_context().add_class("search-result-row") return label - def _create_menu(self) -> Gtk.PopoverMenu: - self.menu = Gtk.PopoverMenu() + def _create_downloads_popover(self) -> Gtk.PopoverMenu: + menu = Gtk.PopoverMenu() + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, name="main-menu-box") - self.connected_to_label = self._create_label("", name="connected-to-label") - self.connected_to_label.set_markup( - 'Not Connected to a Server' + download_settings = Gtk.ModelButton( + text="Clear Cache", menu_name="clear-cache", name="menu-item-clear-cache", ) + download_settings.get_style_context().add_class("menu-button") + vbox.add(download_settings) + + menu.add(vbox) + return menu + + def _create_server_connection_popover(self) -> Gtk.PopoverMenu: + menu = Gtk.PopoverMenu() + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + + menu.add(vbox) + return menu + + def _create_main_menu(self) -> Gtk.PopoverMenu: + main_menu = Gtk.PopoverMenu() + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, name="main-menu-box") + + # TODO + # Current Server + # current_server_box = Gtk.Box(name="connected-to-box") + + # self.connected_to_label = self._create_label("") + # self.connected_to_label.set_markup( + # 'Not connected to any music source' + # ) + # current_server_box.add(self.connected_to_label) + + # edit_button = IconButton("document-edit-symbolic", "Edit the current server") + # edit_button.connect("clicked", lambda _: print("edit")) # TODO + # current_server_box.pack_end(edit_button, False, False, 5) + + # vbox.add(current_server_box) + + # # Music Source + # switch_source_button = Gtk.ModelButton( + # text="Switch Music Source", + # menu_name="switch-source", + # name="menu-item-switch-source", + # ) + # switch_source_button.get_style_context().add_class("menu-button") + # vbox.add(switch_source_button) + + # vbox.add(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)) + + # Offline Mode + offline_box, self.offline_mode_switch = self._create_toggle_menu_button( + "Offline Mode", "offline_mode" + ) + vbox.add(offline_box) + + download_settings = Gtk.ModelButton( + text="Download Settings", + menu_name="download-settings", + name="menu-item-download-settings", + ) + download_settings.get_style_context().add_class("menu-button") + vbox.add(download_settings) + + vbox.add(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)) + + # Notifications + notifications_box, self.notification_switch = self._create_toggle_menu_button( + "Notifications", "song_play_notification" + ) + vbox.add(notifications_box) + + # Replay Gain + replay_gain_box = Gtk.Box() + replay_gain_box.add(Gtk.Label(label="Replay Gain")) + + replay_gain_option_store = Gtk.ListStore(str, str) + for id, option in (("no", "Disabled"), ("track", "Track"), ("album", "Album")): + replay_gain_option_store.append([id, option]) + + self.replay_gain_options = Gtk.ComboBox.new_with_model(replay_gain_option_store) + self.replay_gain_options.set_id_column(0) + renderer_text = Gtk.CellRendererText() + self.replay_gain_options.pack_start(renderer_text, True) + self.replay_gain_options.add_attribute(renderer_text, "text", 1) + self.replay_gain_options.connect("changed", self._on_replay_gain_change) + + replay_gain_box.pack_end(self.replay_gain_options, False, False, 0) + replay_gain_box.get_style_context().add_class("menu-button") + vbox.add(replay_gain_box) + + vbox.add(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)) + + # Serve Local Files to Chromecast + serve_over_lan, self.serve_over_lan_switch = self._create_toggle_menu_button( + "Serve Local Files to Devices on the LAN", "serve_over_lan" + ) + vbox.add(serve_over_lan) + + # Server Port + server_port_box = Gtk.Box() + server_port_box.add(Gtk.Label(label="LAN Server Port Number")) + + self.port_number_entry = Gtk.SpinButton.new_with_range(8000, 9000, 1) + server_port_box.pack_end(self.port_number_entry, False, False, 0) + server_port_box.get_style_context().add_class("menu-button") + + vbox.add(server_port_box) menu_items = [ - (None, self.connected_to_label), - ("app.configure-servers", Gtk.ModelButton(text="Configure Servers"),), + ("app.configure-servers", Gtk.ModelButton(text="Configure Servers")), ("app.settings", Gtk.ModelButton(text="Settings")), ] - vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) for name, item in menu_items: if name: item.set_action_name(name) item.get_style_context().add_class("menu-button") vbox.pack_start(item, False, True, 0) - self.menu.add(vbox) - return self.menu + main_menu.add(vbox) + return main_menu + + def _create_toggle_menu_button(self, label: str, settings_name: str) -> Gtk.Box: + def on_active_change(toggle: Gtk.Switch, _): + self._emit_settings_change(**{settings_name: toggle.get_active()}) + + box = Gtk.Box() + box.add(Gtk.Label(label=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_search_popup(self) -> Gtk.PopoverMenu: self.search_popup = Gtk.PopoverMenu(modal=False) @@ -265,7 +417,7 @@ class MainWindow(Gtk.ApplicationWindow): # Event Listeners # ========================================================================= def _on_button_release(self, win: Any, event: Gdk.EventButton) -> bool: - if not self._event_in_widgets(event, self.search_entry, self.search_popup,): + if not self._event_in_widgets(event, self.search_entry, self.search_popup): self._hide_search() if not self._event_in_widgets( @@ -284,9 +436,22 @@ class MainWindow(Gtk.ApplicationWindow): return False - def _on_menu_clicked(self, *args): - self.menu.popup() - self.menu.show_all() + def _on_downloads_menu_clicked(self, *args): + self.downloads_popover.popup() + self.downloads_popover.show_all() + + def _on_server_connection_menu_clicked(self, *args): + self.server_connection_popover.popup() + self.server_connection_popover.show_all() + + def _on_main_menu_clicked(self, *args): + self.main_menu_popover.popup() + self.main_menu_popover.show_all() + + def _on_replay_gain_change(self, combo: Gtk.ComboBox): + self._emit_settings_change( + replay_gain=ReplayGainType.from_string(combo.get_active_id()) + ) def _on_search_entry_focus(self, *args): self._show_search() @@ -339,6 +504,11 @@ class MainWindow(Gtk.ApplicationWindow): # Helper Functions # ========================================================================= + def _emit_settings_change(self, **kwargs): + if self._updating_settings: + return + self.emit("refresh-window", {"__settings__": kwargs}, False) + def _show_search(self): self.search_entry.set_size_request(300, -1) self.search_popup.show_all() From 913ae772bb15c610cef2e3374b278264881b6f4a Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Fri, 22 May 2020 02:07:21 -0600 Subject: [PATCH 02/41] WE ARE VICTORIOUS Also known as we got the icon working --- sublime/app.py | 6 +++++- sublime/ui/common/icon_button.py | 7 ------- sublime/ui/icons/server-subsonic-error-symbolic.svg | 1 + sublime/ui/icons/server-subsonic-symbolic.svg | 1 + sublime/ui/main.py | 3 +-- 5 files changed, 8 insertions(+), 10 deletions(-) create mode 100644 sublime/ui/icons/server-subsonic-error-symbolic.svg create mode 100644 sublime/ui/icons/server-subsonic-symbolic.svg diff --git a/sublime/app.py b/sublime/app.py index 19a7cd2..edf29ba 100644 --- a/sublime/app.py +++ b/sublime/app.py @@ -106,6 +106,10 @@ class SublimeMusicApp(Gtk.Application): self.window.present() return + # Configure Icons + icon_dir = Path(__file__).parent.joinpath("ui", "icons") + Gtk.IconTheme.get_default().append_search_path(str(icon_dir)) + # Windows are associated with the application when the last one is # closed the application shuts down. self.window = MainWindow(application=self, title="Sublime Music") @@ -492,7 +496,7 @@ class SublimeMusicApp(Gtk.Application): ): if settings := state_updates.get("__settings__"): for k, v in settings.items(): - print('SET', k, v) + print("SET", k, v) setattr(self.app_config, k, v) del state_updates["__settings__"] diff --git a/sublime/ui/common/icon_button.py b/sublime/ui/common/icon_button.py index 87698fc..68f8808 100644 --- a/sublime/ui/common/icon_button.py +++ b/sublime/ui/common/icon_button.py @@ -76,7 +76,6 @@ class IconMenuButton(Gtk.MenuButton): def __init__( self, icon_name: Optional[str] = None, - icon_image_filename: Optional[str] = None, tooltip_text: str = "", relief: bool = False, icon_size: Gtk.IconSize = Gtk.IconSize.BUTTON, @@ -97,12 +96,6 @@ class IconMenuButton(Gtk.MenuButton): if icon_name: self.image.set_from_icon_name(icon_name, self.icon_size) box.add(self.image) - elif icon_image_filename: - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( - icon_image_filename, -1, 16, True, - ) - self.image.set_from_pixbuf(pixbuf) - box.add(self.image) if label is not None: box.add(Gtk.Label(label=label)) diff --git a/sublime/ui/icons/server-subsonic-error-symbolic.svg b/sublime/ui/icons/server-subsonic-error-symbolic.svg new file mode 100644 index 0000000..321c20a --- /dev/null +++ b/sublime/ui/icons/server-subsonic-error-symbolic.svg @@ -0,0 +1 @@ + diff --git a/sublime/ui/icons/server-subsonic-symbolic.svg b/sublime/ui/icons/server-subsonic-symbolic.svg new file mode 100644 index 0000000..451276f --- /dev/null +++ b/sublime/ui/icons/server-subsonic-symbolic.svg @@ -0,0 +1 @@ + diff --git a/sublime/ui/main.py b/sublime/ui/main.py index 4a423e0..635e3f1 100644 --- a/sublime/ui/main.py +++ b/sublime/ui/main.py @@ -203,8 +203,7 @@ class MainWindow(Gtk.ApplicationWindow): # Server icon and change server dropdown self.server_connection_popover = self._create_server_connection_popover() self.server_connection_menu_button = IconMenuButton( - name="server-connection-icon", - icon_image_filename="/home/sumner/tmp/server-subsonic-symbolic.svg", + "server-subsonic-error-symbolic", tooltip_text="Server connection settings", popover=self.server_connection_popover, ) From 3a6da001c25ecac8c046f81b20927fc61b08a8bc Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Fri, 22 May 2020 02:45:07 -0600 Subject: [PATCH 03/41] Added icons for play next and add to queue and chromecast; start work on clear cache options --- sublime/ui/app_styles.css | 5 ++++ sublime/ui/common/album_with_songs.py | 4 +-- sublime/ui/icons/chromecast-symbolic.svg | 3 +++ sublime/ui/icons/queue-back-symbolic.svg | 1 + sublime/ui/icons/queue-front-symbolic.svg | 1 + sublime/ui/main.py | 33 ++++++++++++++++++++--- sublime/ui/player_controls.py | 2 +- 7 files changed, 42 insertions(+), 7 deletions(-) create mode 100644 sublime/ui/icons/chromecast-symbolic.svg create mode 100644 sublime/ui/icons/queue-back-symbolic.svg create mode 100644 sublime/ui/icons/queue-front-symbolic.svg diff --git a/sublime/ui/app_styles.css b/sublime/ui/app_styles.css index c65101e..c330b36 100644 --- a/sublime/ui/app_styles.css +++ b/sublime/ui/app_styles.css @@ -3,6 +3,11 @@ /* box-shadow: 0px 0px 3px green; */ } +#menu-label { + margin-top: 10px; + margin-bottom: 10px; +} + #main-menu-box { min-width: 230px; } diff --git a/sublime/ui/common/album_with_songs.py b/sublime/ui/common/album_with_songs.py index dafe463..9d2369b 100644 --- a/sublime/ui/common/album_with_songs.py +++ b/sublime/ui/common/album_with_songs.py @@ -84,14 +84,14 @@ class AlbumWithSongs(Gtk.Box): album_title_and_buttons.pack_start(self.shuffle_btn, False, False, 5) self.play_next_btn = IconButton( - "go-top-symbolic", + "queue-front-symbolic", "Play all of the songs in this album next", sensitive=False, ) album_title_and_buttons.pack_start(self.play_next_btn, False, False, 5) self.add_to_queue_btn = IconButton( - "go-jump-symbolic", + "queue-back-symbolic", "Add all the songs in this album to the end of the play queue", sensitive=False, ) diff --git a/sublime/ui/icons/chromecast-symbolic.svg b/sublime/ui/icons/chromecast-symbolic.svg new file mode 100644 index 0000000..16d727f --- /dev/null +++ b/sublime/ui/icons/chromecast-symbolic.svg @@ -0,0 +1,3 @@ + + + diff --git a/sublime/ui/icons/queue-back-symbolic.svg b/sublime/ui/icons/queue-back-symbolic.svg new file mode 100644 index 0000000..bbf25bb --- /dev/null +++ b/sublime/ui/icons/queue-back-symbolic.svg @@ -0,0 +1 @@ + diff --git a/sublime/ui/icons/queue-front-symbolic.svg b/sublime/ui/icons/queue-front-symbolic.svg new file mode 100644 index 0000000..e8e1662 --- /dev/null +++ b/sublime/ui/icons/queue-front-symbolic.svg @@ -0,0 +1 @@ + diff --git a/sublime/ui/main.py b/sublime/ui/main.py index 635e3f1..b62f514 100644 --- a/sublime/ui/main.py +++ b/sublime/ui/main.py @@ -233,13 +233,38 @@ class MainWindow(Gtk.ApplicationWindow): def _create_downloads_popover(self) -> Gtk.PopoverMenu: menu = Gtk.PopoverMenu() - vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, name="main-menu-box") + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, name="downloads-menu") - download_settings = Gtk.ModelButton( + vbox.add(self._create_label("Current Downloads", name="menu-label")) + + clear_cache = Gtk.ModelButton( text="Clear Cache", menu_name="clear-cache", name="menu-item-clear-cache", ) - download_settings.get_style_context().add_class("menu-button") - vbox.add(download_settings) + clear_cache.get_style_context().add_class("menu-button") + vbox.add(clear_cache) + + # Create the "Add song(s) to playlist" sub-menu. + clear_cache_options = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + + # Back button + clear_cache_options.add( + Gtk.ModelButton(inverted=True, centered=True, menu_name="main") + ) + + # Clear Song File Cache + menu_items = [ + ("Clear Song File Cache", lambda _: print("clear song file cache")), + ("Clear Metadata Cache", lambda _: print("clear metadata cache")), + ("Clear Entire Cache", lambda _: print("clear entire cache")), + ] + for text, clicked_fn in menu_items: + clear_song_cache = Gtk.ModelButton(text=text) + clear_song_cache.get_style_context().add_class("menu-button") + clear_song_cache.connect("clicked", clicked_fn) + clear_cache_options.pack_start(clear_song_cache, False, True, 0) + + menu.add(clear_cache_options) + menu.child_set_property(clear_cache_options, "submenu", "clear-cache") menu.add(vbox) return menu diff --git a/sublime/ui/player_controls.py b/sublime/ui/player_controls.py index 0f6876f..a96ba53 100644 --- a/sublime/ui/player_controls.py +++ b/sublime/ui/player_controls.py @@ -609,7 +609,7 @@ class PlayerControls(Gtk.ActionBar): # Device button (for chromecast) self.device_button = IconButton( - "video-display-symbolic", + "chromecast-symbolic", "Show available audio output devices", icon_size=Gtk.IconSize.LARGE_TOOLBAR, ) From 999d7e0499308deb7d063ca92825c5a8dc2b0835 Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Fri, 22 May 2020 11:14:59 -0600 Subject: [PATCH 04/41] Migrate all settings to popup --- sublime/adapters/manager.py | 10 +- sublime/app.py | 42 +----- sublime/config.py | 22 ++- sublime/ui/app_styles.css | 19 ++- sublime/ui/common/icon_button.py | 19 +-- sublime/ui/main.py | 249 +++++++++++++++++++++---------- sublime/ui/settings.py | 50 ------- 7 files changed, 219 insertions(+), 192 deletions(-) delete mode 100644 sublime/ui/settings.py diff --git a/sublime/adapters/manager.py b/sublime/adapters/manager.py index 8b84da3..85b2cfd 100644 --- a/sublime/adapters/manager.py +++ b/sublime/adapters/manager.py @@ -759,11 +759,11 @@ class AdapterManager: # TODO (#189): allow this to take a set of schemes @staticmethod def get_song_filename_or_stream( - song: Song, format: str = None, force_stream: bool = False, + song: Song, format: str = None, allow_song_downloads: bool = True, ) -> str: assert AdapterManager._instance cached_song_filename = None - if AdapterManager._can_use_cache(force_stream, "get_song_uri"): + if AdapterManager._can_use_cache(False, "get_song_uri"): assert AdapterManager._instance.caching_adapter try: return AdapterManager._instance.caching_adapter.get_song_uri( @@ -779,7 +779,7 @@ class AdapterManager: ) if not AdapterManager._ground_truth_can_do("get_song_uri"): - if force_stream or cached_song_filename is None: + if not allow_song_downloads or cached_song_filename is None: raise Exception("Can't stream the song.") return cached_song_filename @@ -787,7 +787,9 @@ class AdapterManager: # get the hash of the song and compare here. That way of the cache gets blown # away, but not the song files, it will not have to re-download. - if force_stream and not AdapterManager._ground_truth_can_do("stream"): + if not allow_song_downloads and not AdapterManager._ground_truth_can_do( + "stream" + ): raise Exception("Can't stream the song.") return AdapterManager._instance.ground_truth_adapter.get_song_uri( diff --git a/sublime/app.py b/sublime/app.py index edf29ba..c55eaab 100644 --- a/sublime/app.py +++ b/sublime/app.py @@ -34,12 +34,11 @@ except Exception: from .adapters import AdapterManager, AlbumSearchQuery, Result from .adapters.api_objects import Playlist, PlayQueue, Song -from .config import AppConfiguration, ReplayGainType +from .config import AppConfiguration from .dbus import dbus_propagate, DBusManager from .players import ChromecastPlayer, MPVPlayer, Player, PlayerEvent from .ui.configure_servers import ConfigureServersDialog from .ui.main import MainWindow -from .ui.settings import SettingsDialog from .ui.state import RepeatType, UIState @@ -70,7 +69,6 @@ class SublimeMusicApp(Gtk.Application): # Add action for menu items. add_action("configure-servers", self.on_configure_servers) - add_action("settings", self.on_settings) # Add actions for player controls add_action("play-pause", self.on_play_pause) @@ -511,33 +509,6 @@ class SublimeMusicApp(Gtk.Application): def on_configure_servers(self, *args): self.show_configure_servers_dialog() - def on_settings(self, *args): - """Show the Settings dialog.""" - dialog = SettingsDialog(self.window, self.app_config) - result = dialog.run() - if result == Gtk.ResponseType.OK: - self.app_config.port_number = int(dialog.data["port_number"].get_text()) - self.app_config.always_stream = dialog.data["always_stream"].get_active() - self.app_config.download_on_stream = dialog.data[ - "download_on_stream" - ].get_active() - self.app_config.song_play_notification = dialog.data[ - "song_play_notification" - ].get_active() - self.app_config.serve_over_lan = dialog.data["serve_over_lan"].get_active() - self.app_config.prefetch_amount = dialog.data[ - "prefetch_amount" - ].get_value_as_int() - self.app_config.concurrent_download_limit = dialog.data[ - "concurrent_download_limit" - ].get_value_as_int() - self.app_config.replay_gain = ReplayGainType.from_string( - dialog.data["replay_gain"].get_active_id() - ) - self.app_config.save() - self.reset_state() - dialog.destroy() - def on_window_go_to(self, win: Any, action: str, value: str): { "album": self.on_go_to_album, @@ -982,7 +953,7 @@ class SublimeMusicApp(Gtk.Application): return uri = AdapterManager.get_song_filename_or_stream( - song, force_stream=self.app_config.always_stream, + song, allow_song_downloads=self.app_config.allow_song_downloads, ) # Prevent it from doing the thing where it continually loads @@ -1065,8 +1036,9 @@ class SublimeMusicApp(Gtk.Application): "Unable to display notification. Is a notification daemon running?" # noqa: E501 ) - # Download current song and prefetch songs. Only do this if - # download_on_stream is True and always_stream is off. + # Download current song and prefetch songs. Only do this if the adapter can + # download songs and allow_song_downloads is True and download_on_stream is + # True. def on_song_download_complete(song_id: str): if order_token != self.song_playing_order_token: return @@ -1094,8 +1066,8 @@ class SublimeMusicApp(Gtk.Application): self.update_window() if ( - self.app_config.download_on_stream - and not self.app_config.always_stream + self.app_config.allow_song_downloads + and self.app_config.download_on_stream and AdapterManager.can_batch_download_songs() ): song_ids = [song.id] diff --git a/sublime/config.py b/sublime/config.py index e18bbe0..906dd1f 100644 --- a/sublime/config.py +++ b/sublime/config.py @@ -84,15 +84,18 @@ class AppConfiguration: song_play_notification: bool = True offline_mode: bool = False serve_over_lan: bool = True - - # TODO this should probably be moved to the cache adapter settings - max_cache_size_mb: int = -1 # -1 means unlimited - always_stream: bool = False # always stream instead of downloading songs + port_number: int = 8282 + replay_gain: ReplayGainType = ReplayGainType.NO + allow_song_downloads: bool = True download_on_stream: bool = True # also download when streaming a song prefetch_amount: int = 3 concurrent_download_limit: int = 5 - port_number: int = 8282 - replay_gain: ReplayGainType = ReplayGainType.NO + + # TODO this should probably be moved to the cache adapter settings + max_cache_size_mb: int = -1 # -1 means unlimited + + # Deprecated + always_stream: bool = False # always stream instead of downloading songs @staticmethod def load_from_file(filename: Path) -> "AppConfiguration": @@ -124,11 +127,16 @@ class AppConfiguration: self._state = None self._current_server_hash = None + self.migrate() def migrate(self): for server in self.servers: server.migrate() - self.version = 3 + + if self.version < 4: + self.allow_song_downloads = not self.always_stream + + self.version = 4 self.state.migrate() @property diff --git a/sublime/ui/app_styles.css b/sublime/ui/app_styles.css index c330b36..3ea4aff 100644 --- a/sublime/ui/app_styles.css +++ b/sublime/ui/app_styles.css @@ -3,9 +3,22 @@ /* box-shadow: 0px 0px 3px green; */ } -#menu-label { - margin-top: 10px; - margin-bottom: 10px; +#connected-to-label { + margin: 10px 0; +} + +#menu-header { + margin: 10px 15px 10px 5px; + font-weight: bold; +} + +#current-downloads-list { + min-height: 30px; + min-width: 200px; +} + +.menu-label { + margin-right: 15px; } #main-menu-box { diff --git a/sublime/ui/common/icon_button.py b/sublime/ui/common/icon_button.py index 68f8808..7233a70 100644 --- a/sublime/ui/common/icon_button.py +++ b/sublime/ui/common/icon_button.py @@ -1,6 +1,6 @@ from typing import Any, Optional -from gi.repository import GdkPixbuf, Gtk +from gi.repository import Gtk class IconButton(Gtk.Button): @@ -18,8 +18,7 @@ class IconButton(Gtk.Button): self.icon_size = icon_size box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, name="icon-button-box") - self.image = Gtk.Image() - self.image.set_from_icon_name(icon_name, self.icon_size) + self.image = Gtk.Image.new_from_icon_name(icon_name, self.icon_size) box.add(self.image) if label is not None: @@ -49,8 +48,7 @@ class IconToggleButton(Gtk.ToggleButton): self.icon_size = icon_size box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, name="icon-button-box") - self.image = Gtk.Image() - self.image.set_from_icon_name(icon_name, self.icon_size) + self.image = Gtk.Image.new_from_icon_name(icon_name, self.icon_size) box.add(self.image) if label is not None: @@ -77,7 +75,7 @@ class IconMenuButton(Gtk.MenuButton): self, icon_name: Optional[str] = None, tooltip_text: str = "", - relief: bool = False, + relief: bool = True, icon_size: Gtk.IconSize = Gtk.IconSize.BUTTON, label: str = None, popover: Any = None, @@ -92,16 +90,13 @@ class IconMenuButton(Gtk.MenuButton): self.icon_size = icon_size box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, name="icon-button-box") - self.image = Gtk.Image() - if icon_name: - self.image.set_from_icon_name(icon_name, self.icon_size) - box.add(self.image) + self.image = Gtk.Image.new_from_icon_name(icon_name, self.icon_size) + box.add(self.image) if label is not None: box.add(Gtk.Label(label=label)) - if not relief: - self.props.relief = Gtk.ReliefStyle.NONE + self.props.relief = Gtk.ReliefStyle.NORMAL self.add(box) self.set_tooltip_text(tooltip_text) diff --git a/sublime/ui/main.py b/sublime/ui/main.py index b62f514..16ba52f 100644 --- a/sublime/ui/main.py +++ b/sublime/ui/main.py @@ -1,7 +1,7 @@ from functools import partial -from typing import Any, Callable, Optional, Set +from typing import Any, Callable, Optional, Set, Tuple -from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango +from gi.repository import Gdk, GLib, GObject, Gtk, Pango from sublime.adapters import AdapterManager, api_objects as API, Result from sublime.config import AppConfiguration, ReplayGainType @@ -111,23 +111,36 @@ class MainWindow(Gtk.ApplicationWindow): self.notification_revealer.set_reveal_child(False) # Update the Connected to label on the popup menu. - # if app_config.server: - # self.connected_to_label.set_markup( - # f"Connected to {app_config.server.name}" - # ) - # else: - # self.connected_to_label.set_markup( - # 'Not Connected to a Server' - # ) + if app_config.server: + self.connected_to_label.set_markup( + f"Connected to {app_config.server.name}" + ) + else: + self.connected_to_label.set_markup("Not Connected to a Server") self._updating_settings = True - self.offline_mode_switch.set_active(app_config.offline_mode) + # Main Settings + offline_mode = app_config.offline_mode + self.offline_mode_switch.set_active(offline_mode) + self.download_settings_button.set_sensitive(not offline_mode) self.notification_switch.set_active(app_config.song_play_notification) self.replay_gain_options.set_active_id(app_config.replay_gain.as_string()) self.serve_over_lan_switch.set_active(app_config.serve_over_lan) self.port_number_entry.set_value(app_config.port_number) + # Download Settings + allow_song_downloads = app_config.allow_song_downloads + self.allow_song_downloads_switch.set_active(allow_song_downloads) + self.download_on_stream_switch.set_active(app_config.download_on_stream) + self.prefetch_songs_entry.set_value(app_config.prefetch_amount) + self.max_concurrent_downloads_entry.set_value( + app_config.concurrent_download_limit + ) + self.download_on_stream_switch.set_sensitive(allow_song_downloads) + self.prefetch_songs_entry.set_sensitive(allow_song_downloads) + self.max_concurrent_downloads_entry.set_sensitive(allow_song_downloads) + self._updating_settings = False self.stack.set_visible_child_name(app_config.state.current_tab) @@ -231,17 +244,72 @@ class MainWindow(Gtk.ApplicationWindow): label.get_style_context().add_class("search-result-row") return label + 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, **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) + return model_button + + def _create_spin_button_menu_item( + self, label: str, low: int, high: int, step: int, settings_name: str + ) -> Tuple[Gtk.Box, Gtk.Entry]: + def on_change(entry: Gtk.SpinButton) -> bool: + self._emit_settings_change(**{settings_name: int(entry.get_value())}) + return False + + box = Gtk.Box() + box.add(spin_button_label := Gtk.Label(label=label)) + spin_button_label.get_style_context().add_class("menu-label") + + entry = Gtk.SpinButton.new_with_range(low, high, step) + entry.connect("value-changed", on_change) + box.pack_end(entry, False, False, 0) + box.get_style_context().add_class("menu-button") + return box, entry + def _create_downloads_popover(self) -> Gtk.PopoverMenu: menu = Gtk.PopoverMenu() vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, name="downloads-menu") - vbox.add(self._create_label("Current Downloads", name="menu-label")) - - clear_cache = Gtk.ModelButton( - text="Clear Cache", menu_name="clear-cache", name="menu-item-clear-cache", + current_downloads_header = Gtk.Box() + current_downloads_header.add( + current_downloads_label := Gtk.Label( + label="Current Downloads", name="menu-header", + ) ) - clear_cache.get_style_context().add_class("menu-button") + current_downloads_label.get_style_context().add_class("menu-label") + cancel_all_button = IconButton( + "process-stop-symbolic", "Cancel all downloads", sensitive=False + ) + current_downloads_header.pack_end(cancel_all_button, False, False, 0) + vbox.add(current_downloads_header) + + current_downloads_box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, name="current-downloads-list" + ) + vbox.add(current_downloads_box) + + clear_cache = self._create_model_button("Clear Cache", menu_name="clear-cache") vbox.add(clear_cache) + menu.add(vbox) # Create the "Add song(s) to playlist" sub-menu. clear_cache_options = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) @@ -258,21 +326,42 @@ class MainWindow(Gtk.ApplicationWindow): ("Clear Entire Cache", lambda _: print("clear entire cache")), ] for text, clicked_fn in menu_items: - clear_song_cache = Gtk.ModelButton(text=text) - clear_song_cache.get_style_context().add_class("menu-button") - clear_song_cache.connect("clicked", clicked_fn) + clear_song_cache = self._create_model_button(text, clicked_fn) clear_cache_options.pack_start(clear_song_cache, False, True, 0) menu.add(clear_cache_options) menu.child_set_property(clear_cache_options, "submenu", "clear-cache") - menu.add(vbox) return menu def _create_server_connection_popover(self) -> Gtk.PopoverMenu: menu = Gtk.PopoverMenu() vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + # Current Server + self.connected_to_label = self._create_label( + "Not connected to any music source", name="connected-to-label" + ) + vbox.add(self.connected_to_label) + + edit_button = self._create_model_button("Edit...", lambda _: print("edit")) + vbox.add(edit_button) + + vbox.add(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)) + + music_provider_button = self._create_model_button( + "Switch Music Provider", + lambda _: print("switch"), + menu_name="switch-provider", + ) + music_provider_button.set_action_name("app.configure-servers") + vbox.add(music_provider_button) + + add_new_music_provider_button = self._create_model_button( + "Add New Music Provider...", lambda _: print("add new") + ) + vbox.add(add_new_music_provider_button) + menu.add(vbox) return menu @@ -280,46 +369,19 @@ class MainWindow(Gtk.ApplicationWindow): main_menu = Gtk.PopoverMenu() vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, name="main-menu-box") - # TODO - # Current Server - # current_server_box = Gtk.Box(name="connected-to-box") - - # self.connected_to_label = self._create_label("") - # self.connected_to_label.set_markup( - # 'Not connected to any music source' - # ) - # current_server_box.add(self.connected_to_label) - - # edit_button = IconButton("document-edit-symbolic", "Edit the current server") - # edit_button.connect("clicked", lambda _: print("edit")) # TODO - # current_server_box.pack_end(edit_button, False, False, 5) - - # vbox.add(current_server_box) - - # # Music Source - # switch_source_button = Gtk.ModelButton( - # text="Switch Music Source", - # menu_name="switch-source", - # name="menu-item-switch-source", - # ) - # switch_source_button.get_style_context().add_class("menu-button") - # vbox.add(switch_source_button) - - # vbox.add(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)) - # Offline Mode offline_box, self.offline_mode_switch = self._create_toggle_menu_button( "Offline Mode", "offline_mode" ) vbox.add(offline_box) - download_settings = Gtk.ModelButton( + self.download_settings_button = Gtk.ModelButton( text="Download Settings", menu_name="download-settings", name="menu-item-download-settings", ) - download_settings.get_style_context().add_class("menu-button") - vbox.add(download_settings) + self.download_settings_button.get_style_context().add_class("menu-button") + vbox.add(self.download_settings_button) vbox.add(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)) @@ -331,7 +393,8 @@ class MainWindow(Gtk.ApplicationWindow): # Replay Gain replay_gain_box = Gtk.Box() - replay_gain_box.add(Gtk.Label(label="Replay Gain")) + replay_gain_box.add(replay_gain_label := Gtk.Label(label="Replay Gain")) + replay_gain_label.get_style_context().add_class("menu-label") replay_gain_option_store = Gtk.ListStore(str, str) for id, option in (("no", "Disabled"), ("track", "Track"), ("album", "Album")): @@ -357,41 +420,65 @@ class MainWindow(Gtk.ApplicationWindow): vbox.add(serve_over_lan) # Server Port - server_port_box = Gtk.Box() - server_port_box.add(Gtk.Label(label="LAN Server Port Number")) - - self.port_number_entry = Gtk.SpinButton.new_with_range(8000, 9000, 1) - server_port_box.pack_end(self.port_number_entry, False, False, 0) - server_port_box.get_style_context().add_class("menu-button") - + server_port_box, self.port_number_entry = self._create_spin_button_menu_item( + "LAN Server Port Number", 8000, 9000, 1, "port_number" + ) vbox.add(server_port_box) - menu_items = [ - ("app.configure-servers", Gtk.ModelButton(text="Configure Servers")), - ("app.settings", Gtk.ModelButton(text="Settings")), - ] - - for name, item in menu_items: - if name: - item.set_action_name(name) - item.get_style_context().add_class("menu-button") - vbox.pack_start(item, False, True, 0) - main_menu.add(vbox) + + # Add the download settings sub-menu after the main menu vbox to make sure that + # it doesn't get shown first. + download_settings_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + + # Back button + download_settings_vbox.add( + Gtk.ModelButton(inverted=True, centered=True, menu_name="main") + ) + + # Allow Song Downloads + ( + allow_song_downloads, + self.allow_song_downloads_switch, + ) = self._create_toggle_menu_button( + "Allow Song Downloads", "allow_song_downloads" + ) + download_settings_vbox.add(allow_song_downloads) + + # Download on Stream + ( + download_on_stream, + self.download_on_stream_switch, + ) = self._create_toggle_menu_button( + "When Streaming, Also Download Song", "download_on_stream" + ) + download_settings_vbox.add(download_on_stream) + + # Prefetch Songs + ( + prefetch_songs_box, + self.prefetch_songs_entry, + ) = self._create_spin_button_menu_item( + "Number of Songs to Prefetch", 0, 10, 1, "prefetch_amount" + ) + download_settings_vbox.add(prefetch_songs_box) + + # Max Concurrent Downloads + ( + max_concurrent_downloads, + self.max_concurrent_downloads_entry, + ) = self._create_spin_button_menu_item( + "Maximum Concurrent Downloads", 0, 10, 1, "concurrent_download_limit" + ) + download_settings_vbox.add(max_concurrent_downloads) + + main_menu.add(download_settings_vbox) + main_menu.child_set_property( + download_settings_vbox, "submenu", "download-settings" + ) + return main_menu - def _create_toggle_menu_button(self, label: str, settings_name: str) -> Gtk.Box: - def on_active_change(toggle: Gtk.Switch, _): - self._emit_settings_change(**{settings_name: toggle.get_active()}) - - box = Gtk.Box() - box.add(Gtk.Label(label=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_search_popup(self) -> Gtk.PopoverMenu: self.search_popup = Gtk.PopoverMenu(modal=False) diff --git a/sublime/ui/settings.py b/sublime/ui/settings.py deleted file mode 100644 index 58f369b..0000000 --- a/sublime/ui/settings.py +++ /dev/null @@ -1,50 +0,0 @@ -from gi.repository import Gtk - -from .common.edit_form_dialog import EditFormDialog - - -class SettingsDialog(EditFormDialog): - title: str = "Settings" - initial_size = (450, 250) - text_fields = [ - ( - "Port Number (for streaming to Chromecasts on the LAN) *", - "port_number", - False, - ), - ] - boolean_fields = [ - ("Always stream songs", "always_stream"), - ("When streaming, also download song", "download_on_stream"), - ("Show a notification when a song begins to play", "song_play_notification"), - ( - "Serve locally cached files over the LAN to Chromecast devices. *", - "serve_over_lan", - ), - ] - numeric_fields = [ - ( - "How many songs in the play queue do you want to prefetch?", - "prefetch_amount", - (0, 10, 1), - 0, - ), - ( - "How many song downloads do you want to allow concurrently?", - "concurrent_download_limit", - (1, 10, 1), - 5, - ), - ] - option_fields = [ - ("Replay Gain", "replay_gain", ("Disabled", "Track", "Album")), - ] - - def __init__(self, *args, **kwargs): - self.extra_label = Gtk.Label( - label="* Will be appplied after restarting Sublime Music", - justify=Gtk.Justification.LEFT, - use_markup=True, - ) - - super().__init__(*args, **kwargs) From 57937246dfdf1544274338df1454cff4f5a43fbc Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Fri, 22 May 2020 11:59:05 -0600 Subject: [PATCH 05/41] Fix player event bug; Update CHANGELOG --- CHANGELOG.rst | 8 ++++++++ sublime/app.py | 6 +++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8494339..d7c88e0 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,6 +9,14 @@ v0.9.3 * The amount of the song that is cached is now shown while streaming a song. * The notification for resuming a play queue is now a non-modal notification that pops up right above the player controls. + * **New Icons** + + * The Devices button now uses the Chromecast logo + * Custom icons for "Add to play queue", and "Play next" buttons. Thanks to + @samsartor for contributing the SVGs! + * A new icon for the Subsonic adapter. Contributed by @samsartor. + + * Settings has gotten a revamp * This release has a ton of under-the-hood changes to make things more robust and performant. diff --git a/sublime/app.py b/sublime/app.py index c55eaab..9bea6b1 100644 --- a/sublime/app.py +++ b/sublime/app.py @@ -204,13 +204,13 @@ class SublimeMusicApp(Gtk.Application): def on_player_event(event: PlayerEvent): if event.type == PlayerEvent.Type.PLAY_STATE_CHANGE: - assert event.playing + assert event.playing is not None self.app_config.state.playing = event.playing if self.dbus_manager: self.dbus_manager.property_diff() self.update_window() elif event.type == PlayerEvent.Type.VOLUME_CHANGE: - assert event.volume + assert event.volume is not None self.app_config.state.volume = event.volume if self.dbus_manager: self.dbus_manager.property_diff() @@ -220,7 +220,7 @@ class SublimeMusicApp(Gtk.Application): self.loading_state or not self.window or not self.app_config.state.current_song - or not event.stream_cache_duration + or event.stream_cache_duration is None ): return self.app_config.state.song_stream_cache_progress = timedelta( From 620e58eee5b312c8a3175ade8c2a98ae7c80211e Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Fri, 22 May 2020 19:17:14 -0600 Subject: [PATCH 06/41] Fixing a couple UI bugs --- sublime/ui/albums.py | 2 +- sublime/ui/app_styles.css | 24 ++++------- sublime/ui/artists.py | 2 +- sublime/ui/main.py | 89 +++++++++++++++++++-------------------- sublime/ui/playlists.py | 2 + 5 files changed, 55 insertions(+), 64 deletions(-) diff --git a/sublime/ui/albums.py b/sublime/ui/albums.py index 4a9c653..3f0617c 100644 --- a/sublime/ui/albums.py +++ b/sublime/ui/albums.py @@ -763,7 +763,7 @@ class AlbumsGrid(Gtk.Overlay): label=text, tooltip_text=text, ellipsize=Pango.EllipsizeMode.END, - max_width_chars=20, + max_width_chars=22, halign=Gtk.Align.START, ) diff --git a/sublime/ui/app_styles.css b/sublime/ui/app_styles.css index 3ea4aff..6431ce0 100644 --- a/sublime/ui/app_styles.css +++ b/sublime/ui/app_styles.css @@ -4,7 +4,8 @@ } #connected-to-label { - margin: 10px 0; + margin-top: 5px; + font-size: 1.2em; } #menu-header { @@ -12,9 +13,14 @@ font-weight: bold; } +#menu-settings-separator { + margin-bottom: 5px; + font-weight: bold; +} + #current-downloads-list { min-height: 30px; - min-width: 200px; + min-width: 250px; } .menu-label { @@ -87,16 +93,9 @@ } #playlist-album-artwork { - min-height: 200px; - min-width: 200px; margin: 10px 15px 0 10px; } -#playlist-album-artwork.collapsed { - min-height: 70px; - min-width: 70px; -} - #playlist-name, #artist-detail-panel #artist-name { font-size: 40px; margin-bottom: 10px; @@ -239,16 +238,9 @@ } #artist-album-artwork { - min-width: 300px; - min-height: 300px; margin: 10px 15px 0 10px; } -#artist-album-artwork.collapsed { - min-width: 70px; - min-height: 70px; -} - #artist-album-list-artwork { margin: 10px; } diff --git a/sublime/ui/artists.py b/sublime/ui/artists.py index 4c7e660..36f1361 100644 --- a/sublime/ui/artists.py +++ b/sublime/ui/artists.py @@ -316,7 +316,7 @@ class ArtistDetailPanel(Gtk.Box): if order_token != self.update_order_token: return - self.big_info_panel.show() + self.big_info_panel.show_all() if app_config: self.artist_details_expanded = app_config.state.artist_details_expanded diff --git a/sublime/ui/main.py b/sublime/ui/main.py index 16ba52f..f018ef1 100644 --- a/sublime/ui/main.py +++ b/sublime/ui/main.py @@ -112,18 +112,15 @@ class MainWindow(Gtk.ApplicationWindow): # Update the Connected to label on the popup menu. if app_config.server: - self.connected_to_label.set_markup( - f"Connected to {app_config.server.name}" - ) + self.connected_to_label.set_markup(f"{app_config.server.name}") else: - self.connected_to_label.set_markup("Not Connected to a Server") + self.connected_to_label.set_markup("No Music Source Selected") self._updating_settings = True # Main Settings offline_mode = app_config.offline_mode self.offline_mode_switch.set_active(offline_mode) - self.download_settings_button.set_sensitive(not offline_mode) self.notification_switch.set_active(app_config.song_play_notification) self.replay_gain_options.set_active_id(app_config.replay_gain.as_string()) self.serve_over_lan_switch.set_active(app_config.serve_over_lan) @@ -216,7 +213,7 @@ class MainWindow(Gtk.ApplicationWindow): # Server icon and change server dropdown self.server_connection_popover = self._create_server_connection_popover() self.server_connection_menu_button = IconMenuButton( - "server-subsonic-error-symbolic", + "server-subsonic-symbolic", tooltip_text="Server connection settings", popover=self.server_connection_popover, ) @@ -232,10 +229,12 @@ class MainWindow(Gtk.ApplicationWindow): return header - def _create_label(self, text: str, *args, **kwargs) -> Gtk.Label: + def _create_label( + self, text: str, *args, halign=Gtk.Align.START, **kwargs + ) -> Gtk.Label: label = Gtk.Label( use_markup=True, - halign=Gtk.Align.START, + halign=halign, ellipsize=Pango.EllipsizeMode.END, *args, **kwargs, @@ -340,11 +339,21 @@ class MainWindow(Gtk.ApplicationWindow): # Current Server self.connected_to_label = self._create_label( - "Not connected to any music source", name="connected-to-label" + "No Music Source Selected", + name="connected-to-label", + halign=Gtk.Align.CENTER ) vbox.add(self.connected_to_label) - edit_button = self._create_model_button("Edit...", lambda _: print("edit")) + # Offline Mode + offline_box, self.offline_mode_switch = self._create_toggle_menu_button( + "Offline Mode", "offline_mode" + ) + vbox.add(offline_box) + + edit_button = self._create_model_button( + "Edit Configuration...", lambda _: print("edit") + ) vbox.add(edit_button) vbox.add(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)) @@ -369,28 +378,21 @@ class MainWindow(Gtk.ApplicationWindow): main_menu = Gtk.PopoverMenu() vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, name="main-menu-box") - # Offline Mode - offline_box, self.offline_mode_switch = self._create_toggle_menu_button( - "Offline Mode", "offline_mode" - ) - vbox.add(offline_box) - - self.download_settings_button = Gtk.ModelButton( - text="Download Settings", - menu_name="download-settings", - name="menu-item-download-settings", - ) - self.download_settings_button.get_style_context().add_class("menu-button") - vbox.add(self.download_settings_button) - - vbox.add(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)) - # Notifications notifications_box, self.notification_switch = self._create_toggle_menu_button( - "Notifications", "song_play_notification" + "Enable Song Notifications", "song_play_notification" ) vbox.add(notifications_box) + # PLAYER SETTINGS + # ============================================================================== + vbox.add(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)) + vbox.add( + self._create_label( + "Local Playback Settings", name="menu-settings-separator" + ) + ) + # Replay Gain replay_gain_box = Gtk.Box() replay_gain_box.add(replay_gain_label := Gtk.Label(label="Replay Gain")) @@ -412,10 +414,13 @@ class MainWindow(Gtk.ApplicationWindow): vbox.add(replay_gain_box) vbox.add(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)) + vbox.add( + self._create_label("Chromecast Settings", name="menu-settings-separator") + ) # Serve Local Files to Chromecast serve_over_lan, self.serve_over_lan_switch = self._create_toggle_menu_button( - "Serve Local Files to Devices on the LAN", "serve_over_lan" + "Serve Local Files to Chromecasts on the LAN", "serve_over_lan" ) vbox.add(serve_over_lan) @@ -425,15 +430,11 @@ class MainWindow(Gtk.ApplicationWindow): ) vbox.add(server_port_box) - main_menu.add(vbox) - - # Add the download settings sub-menu after the main menu vbox to make sure that - # it doesn't get shown first. - download_settings_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - - # Back button - download_settings_vbox.add( - Gtk.ModelButton(inverted=True, centered=True, menu_name="main") + # DOWNLOAD SETTINGS + # ============================================================================== + vbox.add(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)) + vbox.add( + self._create_label("Download Settings", name="menu-settings-separator") ) # Allow Song Downloads @@ -443,7 +444,7 @@ class MainWindow(Gtk.ApplicationWindow): ) = self._create_toggle_menu_button( "Allow Song Downloads", "allow_song_downloads" ) - download_settings_vbox.add(allow_song_downloads) + vbox.add(allow_song_downloads) # Download on Stream ( @@ -452,7 +453,7 @@ class MainWindow(Gtk.ApplicationWindow): ) = self._create_toggle_menu_button( "When Streaming, Also Download Song", "download_on_stream" ) - download_settings_vbox.add(download_on_stream) + vbox.add(download_on_stream) # Prefetch Songs ( @@ -461,7 +462,7 @@ class MainWindow(Gtk.ApplicationWindow): ) = self._create_spin_button_menu_item( "Number of Songs to Prefetch", 0, 10, 1, "prefetch_amount" ) - download_settings_vbox.add(prefetch_songs_box) + vbox.add(prefetch_songs_box) # Max Concurrent Downloads ( @@ -470,13 +471,9 @@ class MainWindow(Gtk.ApplicationWindow): ) = self._create_spin_button_menu_item( "Maximum Concurrent Downloads", 0, 10, 1, "concurrent_download_limit" ) - download_settings_vbox.add(max_concurrent_downloads) - - main_menu.add(download_settings_vbox) - main_menu.child_set_property( - download_settings_vbox, "submenu", "download-settings" - ) + vbox.add(max_concurrent_downloads) + main_menu.add(vbox) return main_menu def _create_search_popup(self) -> Gtk.PopoverMenu: diff --git a/sublime/ui/playlists.py b/sublime/ui/playlists.py index 4ed5306..d0a5995 100644 --- a/sublime/ui/playlists.py +++ b/sublime/ui/playlists.py @@ -481,6 +481,7 @@ class PlaylistDetailPanel(Gtk.Overlay): self.playlist_name.set_tooltip_text(playlist.name) if self.playlist_details_expanded: + self.playlist_artwork.get_style_context().remove_class("collapsed") self.playlist_name.get_style_context().remove_class("collapsed") self.playlist_box.show_all() self.playlist_artwork.set_image_size(200) @@ -495,6 +496,7 @@ class PlaylistDetailPanel(Gtk.Overlay): self.playlist_stats.set_markup(self._format_stats(playlist)) else: + self.playlist_artwork.get_style_context().add_class("collapsed") self.playlist_name.get_style_context().add_class("collapsed") self.playlist_box.show_all() self.playlist_artwork.set_image_size(70) From 844a3c17cc086e9c86ebbd0f65a92a7db710bd56 Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Fri, 22 May 2020 19:19:02 -0600 Subject: [PATCH 07/41] Fixing mypy errors --- sublime/adapters/manager.py | 7 +++++-- sublime/ui/main.py | 4 ++-- tests/adapter_tests/subsonic_adapter_tests.py | 4 ++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/sublime/adapters/manager.py b/sublime/adapters/manager.py index 85b2cfd..2cac00d 100644 --- a/sublime/adapters/manager.py +++ b/sublime/adapters/manager.py @@ -759,11 +759,14 @@ class AdapterManager: # TODO (#189): allow this to take a set of schemes @staticmethod def get_song_filename_or_stream( - song: Song, format: str = None, allow_song_downloads: bool = True, + song: Song, + format: str = None, + force_stream: bool = False, + allow_song_downloads: bool = True, ) -> str: assert AdapterManager._instance cached_song_filename = None - if AdapterManager._can_use_cache(False, "get_song_uri"): + if AdapterManager._can_use_cache(force_stream, "get_song_uri"): assert AdapterManager._instance.caching_adapter try: return AdapterManager._instance.caching_adapter.get_song_uri( diff --git a/sublime/ui/main.py b/sublime/ui/main.py index f018ef1..344d6b3 100644 --- a/sublime/ui/main.py +++ b/sublime/ui/main.py @@ -230,7 +230,7 @@ class MainWindow(Gtk.ApplicationWindow): return header def _create_label( - self, text: str, *args, halign=Gtk.Align.START, **kwargs + self, text: str, *args, halign: Gtk.Align = Gtk.Align.START, **kwargs ) -> Gtk.Label: label = Gtk.Label( use_markup=True, @@ -341,7 +341,7 @@ class MainWindow(Gtk.ApplicationWindow): self.connected_to_label = self._create_label( "No Music Source Selected", name="connected-to-label", - halign=Gtk.Align.CENTER + halign=Gtk.Align.CENTER, ) vbox.add(self.connected_to_label) diff --git a/tests/adapter_tests/subsonic_adapter_tests.py b/tests/adapter_tests/subsonic_adapter_tests.py index 9cd9ef1..236a91c 100644 --- a/tests/adapter_tests/subsonic_adapter_tests.py +++ b/tests/adapter_tests/subsonic_adapter_tests.py @@ -366,8 +366,8 @@ def test_get_artist(adapter: SubsonicAdapter): assert len(artist.similar_artists) == 20 assert (first_similar := artist.similar_artists[0]) assert first_similar - assert first_similar.name == 'Luke Combs' - assert first_similar.artist_image_url == 'ar-158' + assert first_similar.name == "Luke Combs" + assert first_similar.artist_image_url == "ar-158" def test_get_artist_with_good_image_url(adapter: SubsonicAdapter): From 61dc844000e58ac82904e93c10a91bc6fe05710d Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Fri, 22 May 2020 21:17:03 -0600 Subject: [PATCH 08/41] Added more chromecast icons --- sublime/ui/app_styles.css | 14 +++-- .../icons/chromecast-connected-symbolic.svg | 3 + .../chromecast-connecting-0-symbolic.svg | 8 +++ .../chromecast-connecting-1-symbolic.svg | 8 +++ .../chromecast-connecting-2-symbolic.svg | 7 +++ sublime/ui/icons/chromecast-symbolic.svg | 2 +- sublime/ui/icons/server-online.svg | 62 +++++++++++++++++++ sublime/ui/main.py | 20 ++++++ sublime/ui/player_controls.py | 2 +- 9 files changed, 119 insertions(+), 7 deletions(-) create mode 100644 sublime/ui/icons/chromecast-connected-symbolic.svg create mode 100644 sublime/ui/icons/chromecast-connecting-0-symbolic.svg create mode 100644 sublime/ui/icons/chromecast-connecting-1-symbolic.svg create mode 100644 sublime/ui/icons/chromecast-connecting-2-symbolic.svg create mode 100644 sublime/ui/icons/server-online.svg diff --git a/sublime/ui/app_styles.css b/sublime/ui/app_styles.css index 6431ce0..88aaa96 100644 --- a/sublime/ui/app_styles.css +++ b/sublime/ui/app_styles.css @@ -1,11 +1,15 @@ /* ********** Main ********** */ -#server-connection-icon { /* TODO remove */ - /* box-shadow: 0px 0px 3px green; */ +#connected-to-label { + margin: 5px 15px; + font-size: 1.2em; } -#connected-to-label { - margin-top: 5px; - font-size: 1.2em; +#connected-status-row { + margin-bottom: 5px; +} + +#online-status-icon { + margin-right: 10px; } #menu-header { diff --git a/sublime/ui/icons/chromecast-connected-symbolic.svg b/sublime/ui/icons/chromecast-connected-symbolic.svg new file mode 100644 index 0000000..da2b1de --- /dev/null +++ b/sublime/ui/icons/chromecast-connected-symbolic.svg @@ -0,0 +1,3 @@ + + + diff --git a/sublime/ui/icons/chromecast-connecting-0-symbolic.svg b/sublime/ui/icons/chromecast-connecting-0-symbolic.svg new file mode 100644 index 0000000..7366a7c --- /dev/null +++ b/sublime/ui/icons/chromecast-connecting-0-symbolic.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/sublime/ui/icons/chromecast-connecting-1-symbolic.svg b/sublime/ui/icons/chromecast-connecting-1-symbolic.svg new file mode 100644 index 0000000..8870bff --- /dev/null +++ b/sublime/ui/icons/chromecast-connecting-1-symbolic.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/sublime/ui/icons/chromecast-connecting-2-symbolic.svg b/sublime/ui/icons/chromecast-connecting-2-symbolic.svg new file mode 100644 index 0000000..6e5e810 --- /dev/null +++ b/sublime/ui/icons/chromecast-connecting-2-symbolic.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/sublime/ui/icons/chromecast-symbolic.svg b/sublime/ui/icons/chromecast-symbolic.svg index 16d727f..9bc8f80 100644 --- a/sublime/ui/icons/chromecast-symbolic.svg +++ b/sublime/ui/icons/chromecast-symbolic.svg @@ -1,3 +1,3 @@ - + diff --git a/sublime/ui/icons/server-online.svg b/sublime/ui/icons/server-online.svg new file mode 100644 index 0000000..3149ae5 --- /dev/null +++ b/sublime/ui/icons/server-online.svg @@ -0,0 +1,62 @@ + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/sublime/ui/main.py b/sublime/ui/main.py index 344d6b3..20965fc 100644 --- a/sublime/ui/main.py +++ b/sublime/ui/main.py @@ -338,6 +338,7 @@ class MainWindow(Gtk.ApplicationWindow): vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) # Current Server + self.connected_to_label = self._create_label( "No Music Source Selected", name="connected-to-label", @@ -345,6 +346,25 @@ class MainWindow(Gtk.ApplicationWindow): ) vbox.add(self.connected_to_label) + connected_status_box = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, name="connected-status-row" + ) + connected_status_box.pack_start(Gtk.Box(), True, True, 0) + + self.connection_status_icon = Gtk.Image.new_from_icon_name( + "server-online", Gtk.IconSize.BUTTON + ) + self.connection_status_icon.set_name("online-status-icon") + connected_status_box.add(self.connection_status_icon) + + self.connection_status_label = Gtk.Label( + label="Connected", name="connection-status-label" + ) + connected_status_box.add(self.connection_status_label) + + connected_status_box.pack_start(Gtk.Box(), True, True, 0) + vbox.add(connected_status_box) + # Offline Mode offline_box, self.offline_mode_switch = self._create_toggle_menu_button( "Offline Mode", "offline_mode" diff --git a/sublime/ui/player_controls.py b/sublime/ui/player_controls.py index a96ba53..dd2cb33 100644 --- a/sublime/ui/player_controls.py +++ b/sublime/ui/player_controls.py @@ -609,7 +609,7 @@ class PlayerControls(Gtk.ActionBar): # Device button (for chromecast) self.device_button = IconButton( - "chromecast-symbolic", + "chromecast-connected-symbolic", "Show available audio output devices", icon_size=Gtk.IconSize.LARGE_TOOLBAR, ) From 07076b110f945560f45f8006f7d2a5c7c0dfc0e8 Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Sat, 23 May 2020 01:21:02 -0600 Subject: [PATCH 09/41] Made the Subsonic icons a lot better --- sublime/adapters/adapter_base.py | 14 +++++ sublime/adapters/manager.py | 10 +++ sublime/adapters/subsonic/adapter.py | 4 ++ sublime/app.py | 12 +++- sublime/players.py | 8 ++- .../ui/icons/server-connected-symbolic.svg | 3 + sublime/ui/icons/server-error-symbolic.svg | 3 + sublime/ui/icons/server-offline-symbolic.svg | 3 + sublime/ui/icons/server-online.svg | 62 ------------------- .../server-subsonic-connected-symbolic.svg | 4 ++ .../icons/server-subsonic-error-symbolic.svg | 5 +- .../server-subsonic-offline-symbolic.svg | 4 ++ sublime/ui/icons/server-subsonic-symbolic.svg | 1 - sublime/ui/main.py | 34 +++++++--- sublime/ui/player_controls.py | 10 ++- sublime/ui/playlists.py | 3 +- 16 files changed, 102 insertions(+), 78 deletions(-) create mode 100644 sublime/ui/icons/server-connected-symbolic.svg create mode 100644 sublime/ui/icons/server-error-symbolic.svg create mode 100644 sublime/ui/icons/server-offline-symbolic.svg delete mode 100644 sublime/ui/icons/server-online.svg create mode 100644 sublime/ui/icons/server-subsonic-connected-symbolic.svg create mode 100644 sublime/ui/icons/server-subsonic-offline-symbolic.svg delete mode 100644 sublime/ui/icons/server-subsonic-symbolic.svg diff --git a/sublime/adapters/adapter_base.py b/sublime/adapters/adapter_base.py index 7e6374e..d753705 100644 --- a/sublime/adapters/adapter_base.py +++ b/sublime/adapters/adapter_base.py @@ -296,6 +296,18 @@ class Adapter(abc.ABC): """ return True + @property + @abc.abstractmethod + def ping_status(self) -> bool: + """ + This function should return whether or not the server can be pinged, however it + must do it *instantly*. This function is called *very* often, and even a few + milliseconds delay stacks up quickly and can block the UI thread. + + One option is to ping the server every few seconds and cache the result of the + ping and use that as the result of this function. + """ + # Availability Properties # These properties determine if what things the adapter can be used to do # at the current moment. @@ -751,6 +763,8 @@ class CachingAdapter(Adapter): :param is_cache: whether or not the adapter is being used as a cache. """ + ping_status = True + # Data Ingestion Methods # ================================================================================== class CachedDataKey(Enum): diff --git a/sublime/adapters/manager.py b/sublime/adapters/manager.py index 2cac00d..67e88b0 100644 --- a/sublime/adapters/manager.py +++ b/sublime/adapters/manager.py @@ -206,6 +206,16 @@ class AdapterManager: assert AdapterManager._instance return Result(AdapterManager._instance.ground_truth_adapter.initial_sync) + @staticmethod + def ground_truth_adapter_is_networked() -> bool: + assert AdapterManager._instance + return AdapterManager._instance.ground_truth_adapter.is_networked + + @staticmethod + def get_ping_status() -> bool: + assert AdapterManager._instance + return AdapterManager._instance.ground_truth_adapter.ping_status + @staticmethod def shutdown(): logging.info("AdapterManager shutdown start") diff --git a/sublime/adapters/subsonic/adapter.py b/sublime/adapters/subsonic/adapter.py index eda32a1..c0a3ab5 100644 --- a/sublime/adapters/subsonic/adapter.py +++ b/sublime/adapters/subsonic/adapter.py @@ -160,6 +160,10 @@ class SubsonicAdapter(Adapter): logging.exception(f"Could not connect to {self.hostname}") self._server_available.value = False + @property + def ping_status(self) -> bool: + return self._server_available.value + @property def can_service_requests(self) -> bool: return self._server_available.value diff --git a/sublime/app.py b/sublime/app.py index 9bea6b1..a40fc36 100644 --- a/sublime/app.py +++ b/sublime/app.py @@ -55,6 +55,7 @@ class SublimeMusicApp(Gtk.Application): self.connect("shutdown", self.on_app_shutdown) player: Player + exiting: bool = False def do_startup(self): Gtk.Application.do_startup(self) @@ -254,6 +255,15 @@ class SublimeMusicApp(Gtk.Application): inital_sync_result = AdapterManager.initial_sync() inital_sync_result.add_done_callback(lambda _: self.update_window()) + # Start a loop for testing the ping. + def ping_update(): + if self.exiting: + return + self.update_window() + GLib.timeout_add(5000, ping_update) + + GLib.timeout_add(5000, ping_update) + # Prompt to load the play queue from the server. if self.app_config.server.sync_enabled: self.update_play_state_from_server(prompt_confirm=True) @@ -494,7 +504,6 @@ class SublimeMusicApp(Gtk.Application): ): if settings := state_updates.get("__settings__"): for k, v in settings.items(): - print("SET", k, v) setattr(self.app_config, k, v) del state_updates["__settings__"] @@ -828,6 +837,7 @@ class SublimeMusicApp(Gtk.Application): return False def on_app_shutdown(self, app: "SublimeMusicApp"): + self.exiting = True if glib_notify_exists: Notify.uninit() diff --git a/sublime/players.py b/sublime/players.py index eb3d8a8..9c3a396 100644 --- a/sublime/players.py +++ b/sublime/players.py @@ -30,6 +30,8 @@ class PlayerEvent: PLAY_STATE_CHANGE = 0 VOLUME_CHANGE = 1 STREAM_CACHE_PROGRESS_CHANGE = 2 + CONNECTING = 3 + CONNECTED = 4 type: Type playing: Optional[bool] = False @@ -320,6 +322,7 @@ class ChromecastPlayer(Player): return ChromecastPlayer.executor.submit(do_get_chromecasts) def set_playing_chromecast(self, uuid: str): + self.on_player_event(PlayerEvent(PlayerEvent.Type.CONNECTING)) self.chromecast = next( cc for cc in ChromecastPlayer.chromecasts if cc.device.uuid == UUID(uuid) ) @@ -329,7 +332,8 @@ class ChromecastPlayer(Player): ) self.chromecast.register_status_listener(ChromecastPlayer.cast_status_listener) self.chromecast.wait() - logging.info(f"Using: {self.chromecast.device.friendly_name}") + logging.info(f"Connected to Chromecast: {self.chromecast.device.friendly_name}") + self.on_player_event(PlayerEvent(PlayerEvent.Type.CONNECTED)) def __init__( self, @@ -455,7 +459,7 @@ class ChromecastPlayer(Player): # If it's a local file, then see if we can serve it over the LAN. if not stream_scheme: if self.serve_over_lan: - token = base64.b64encode(os.urandom(64)).decode("ascii") + token = base64.b64encode(os.urandom(8)).decode("ascii") for r in (("+", "."), ("/", "-"), ("=", "_")): token = token.replace(*r) self.server_thread.set_song_and_token(song.id, token) diff --git a/sublime/ui/icons/server-connected-symbolic.svg b/sublime/ui/icons/server-connected-symbolic.svg new file mode 100644 index 0000000..e408399 --- /dev/null +++ b/sublime/ui/icons/server-connected-symbolic.svg @@ -0,0 +1,3 @@ + + + diff --git a/sublime/ui/icons/server-error-symbolic.svg b/sublime/ui/icons/server-error-symbolic.svg new file mode 100644 index 0000000..1c0d296 --- /dev/null +++ b/sublime/ui/icons/server-error-symbolic.svg @@ -0,0 +1,3 @@ + + + diff --git a/sublime/ui/icons/server-offline-symbolic.svg b/sublime/ui/icons/server-offline-symbolic.svg new file mode 100644 index 0000000..b04bccf --- /dev/null +++ b/sublime/ui/icons/server-offline-symbolic.svg @@ -0,0 +1,3 @@ + + + diff --git a/sublime/ui/icons/server-online.svg b/sublime/ui/icons/server-online.svg deleted file mode 100644 index 3149ae5..0000000 --- a/sublime/ui/icons/server-online.svg +++ /dev/null @@ -1,62 +0,0 @@ - - - - - - - - image/svg+xml - - - - - - - - - diff --git a/sublime/ui/icons/server-subsonic-connected-symbolic.svg b/sublime/ui/icons/server-subsonic-connected-symbolic.svg new file mode 100644 index 0000000..016be07 --- /dev/null +++ b/sublime/ui/icons/server-subsonic-connected-symbolic.svg @@ -0,0 +1,4 @@ + + + + diff --git a/sublime/ui/icons/server-subsonic-error-symbolic.svg b/sublime/ui/icons/server-subsonic-error-symbolic.svg index 321c20a..624137b 100644 --- a/sublime/ui/icons/server-subsonic-error-symbolic.svg +++ b/sublime/ui/icons/server-subsonic-error-symbolic.svg @@ -1 +1,4 @@ - + + + + diff --git a/sublime/ui/icons/server-subsonic-offline-symbolic.svg b/sublime/ui/icons/server-subsonic-offline-symbolic.svg new file mode 100644 index 0000000..b03df0c --- /dev/null +++ b/sublime/ui/icons/server-subsonic-offline-symbolic.svg @@ -0,0 +1,4 @@ + + + + diff --git a/sublime/ui/icons/server-subsonic-symbolic.svg b/sublime/ui/icons/server-subsonic-symbolic.svg deleted file mode 100644 index 451276f..0000000 --- a/sublime/ui/icons/server-subsonic-symbolic.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/sublime/ui/main.py b/sublime/ui/main.py index 20965fc..0f8ec1a 100644 --- a/sublime/ui/main.py +++ b/sublime/ui/main.py @@ -116,6 +116,26 @@ class MainWindow(Gtk.ApplicationWindow): else: self.connected_to_label.set_markup("No Music Source Selected") + if AdapterManager.ground_truth_adapter_is_networked: + status_label = "" + if app_config.offline_mode: + status_label = "Offline" + elif AdapterManager.get_ping_status(): + status_label = "Connected" + else: + status_label = "Error" + + self.server_connection_menu_button.set_icon( + f"server-subsonic-{status_label.lower()}-symbolic" + ) + self.connection_status_icon.set_from_icon_name( + f"server-{status_label.lower()}-symbolic", Gtk.IconSize.BUTTON + ) + self.connection_status_label.set_text(status_label) + self.connected_status_box.show_all() + else: + self.connected_status_box.hide() + self._updating_settings = True # Main Settings @@ -213,7 +233,7 @@ class MainWindow(Gtk.ApplicationWindow): # Server icon and change server dropdown self.server_connection_popover = self._create_server_connection_popover() self.server_connection_menu_button = IconMenuButton( - "server-subsonic-symbolic", + "server-subsonic-offline-symbolic", tooltip_text="Server connection settings", popover=self.server_connection_popover, ) @@ -346,24 +366,24 @@ class MainWindow(Gtk.ApplicationWindow): ) vbox.add(self.connected_to_label) - connected_status_box = Gtk.Box( + self.connected_status_box = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, name="connected-status-row" ) - connected_status_box.pack_start(Gtk.Box(), True, True, 0) + self.connected_status_box.pack_start(Gtk.Box(), True, True, 0) self.connection_status_icon = Gtk.Image.new_from_icon_name( "server-online", Gtk.IconSize.BUTTON ) self.connection_status_icon.set_name("online-status-icon") - connected_status_box.add(self.connection_status_icon) + self.connected_status_box.add(self.connection_status_icon) self.connection_status_label = Gtk.Label( label="Connected", name="connection-status-label" ) - connected_status_box.add(self.connection_status_label) + self.connected_status_box.add(self.connection_status_label) - connected_status_box.pack_start(Gtk.Box(), True, True, 0) - vbox.add(connected_status_box) + self.connected_status_box.pack_start(Gtk.Box(), True, True, 0) + vbox.add(self.connected_status_box) # Offline Mode offline_box, self.offline_mode_switch = self._create_toggle_menu_button( diff --git a/sublime/ui/player_controls.py b/sublime/ui/player_controls.py index dd2cb33..ce6125e 100644 --- a/sublime/ui/player_controls.py +++ b/sublime/ui/player_controls.py @@ -115,6 +115,12 @@ class PlayerControls(Gtk.ActionBar): self.play_button.set_sensitive(has_current_song) self.next_button.set_sensitive(has_current_song and has_next_song) + self.device_button.set_icon( + "chromecast{}-symbolic".format( + "" if app_config.state.current_device == "this device" else "-connected" + ) + ) + # Volume button and slider if app_config.state.is_muted: icon_name = "muted" @@ -609,14 +615,14 @@ class PlayerControls(Gtk.ActionBar): # Device button (for chromecast) self.device_button = IconButton( - "chromecast-connected-symbolic", + "chromecast-symbolic", "Show available audio output devices", icon_size=Gtk.IconSize.LARGE_TOOLBAR, ) self.device_button.connect("clicked", self.on_device_click) box.pack_start(self.device_button, False, True, 5) - self.device_popover = Gtk.PopoverMenu(modal=False, name="device-popover",) + self.device_popover = Gtk.PopoverMenu(modal=False, name="device-popover") self.device_popover.set_relative_to(self.device_button) device_popover_box = Gtk.Box( diff --git a/sublime/ui/playlists.py b/sublime/ui/playlists.py index d0a5995..96f94f5 100644 --- a/sublime/ui/playlists.py +++ b/sublime/ui/playlists.py @@ -616,8 +616,7 @@ class PlaylistDetailPanel(Gtk.Overlay): Gtk.ResponseType.CANCEL, ) confirm_dialog.format_secondary_markup( - "Are you sure you want to delete the " - f'"{playlist.name}" playlist?' + 'Are you sure you want to delete the "{playlist.name}" playlist?' ) result = confirm_dialog.run() confirm_dialog.destroy() From a3ce7dd1bd0b6ef94892b6649b0d2c8c8b3d23c2 Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Sat, 23 May 2020 01:21:32 -0600 Subject: [PATCH 10/41] Implemented clearing cache --- sublime/adapters/adapter_base.py | 4 +++ sublime/adapters/filesystem/adapter.py | 22 +++++++++++++- sublime/adapters/manager.py | 18 +++++++++++ sublime/ui/artists.py | 7 +++-- sublime/ui/configure_servers.py | 1 + sublime/ui/main.py | 41 +++++++++++++++++++++++--- sublime/ui/player_controls.py | 4 +-- sublime/ui/playlists.py | 7 +++-- 8 files changed, 93 insertions(+), 11 deletions(-) diff --git a/sublime/adapters/adapter_base.py b/sublime/adapters/adapter_base.py index d753705..5a54434 100644 --- a/sublime/adapters/adapter_base.py +++ b/sublime/adapters/adapter_base.py @@ -783,6 +783,10 @@ class CachingAdapter(Adapter): SONG_FILE = "song_file" SONG_FILE_PERMANENT = "song_file_permanent" + # These are only for clearing the cache, and will only do deletion + ALL_SONGS = "all_songs" + EVERYTHING = "everything" + @abc.abstractmethod def ingest_new_data(self, data_key: CachedDataKey, param: Optional[str], data: Any): """ diff --git a/sublime/adapters/filesystem/adapter.py b/sublime/adapters/filesystem/adapter.py index c0759b4..8c9b52e 100644 --- a/sublime/adapters/filesystem/adapter.py +++ b/sublime/adapters/filesystem/adapter.py @@ -857,4 +857,24 @@ class FilesystemAdapter(CachingAdapter): if cache_info: self._compute_song_filename(cache_info).unlink(missing_ok=True) - cache_info.delete_instance() + elif data_key == CachingAdapter.CachedDataKey.ALL_SONGS: + shutil.rmtree(str(self.music_dir)) + shutil.rmtree(str(self.cover_art_dir)) + self.music_dir.mkdir(parents=True, exist_ok=True) + self.cover_art_dir.mkdir(parents=True, exist_ok=True) + + models.CacheInfo.update({"valid": False}).where( + models.CacheInfo.cache_key == CachingAdapter.CachedDataKey.SONG_FILE + ).execute() + models.CacheInfo.update({"valid": False}).where( + models.CacheInfo.cache_key + == CachingAdapter.CachedDataKey.COVER_ART_FILE + ).execute() + + elif data_key == CachingAdapter.CachedDataKey.EVERYTHING: + self._do_delete_data(CachingAdapter.CachedDataKey.ALL_SONGS, None) + for table in models.ALL_TABLES: + table.truncate_table() + + if cache_info: + cache_info.delete_instance() diff --git a/sublime/adapters/manager.py b/sublime/adapters/manager.py index 67e88b0..f4b1347 100644 --- a/sublime/adapters/manager.py +++ b/sublime/adapters/manager.py @@ -1224,3 +1224,21 @@ class AdapterManager: else cached_statuses[song_id] for song_id in song_ids ] + + @staticmethod + def clear_song_cache(): + assert AdapterManager._instance + if not AdapterManager._instance.caching_adapter: + return + AdapterManager._instance.caching_adapter.delete_data( + CachingAdapter.CachedDataKey.ALL_SONGS, None + ) + + @staticmethod + def clear_entire_cache(): + assert AdapterManager._instance + if not AdapterManager._instance.caching_adapter: + return + AdapterManager._instance.caching_adapter.delete_data( + CachingAdapter.CachedDataKey.EVERYTHING, None + ) diff --git a/sublime/ui/artists.py b/sublime/ui/artists.py index 36f1361..a43483d 100644 --- a/sublime/ui/artists.py +++ b/sublime/ui/artists.py @@ -333,7 +333,6 @@ class ArtistDetailPanel(Gtk.Box): if self.artist_details_expanded: self.artist_artwork.get_style_context().remove_class("collapsed") self.artist_name.get_style_context().remove_class("collapsed") - self.artist_artwork.set_image_size(300) self.artist_indicator.set_text("ARTIST") self.artist_stats.set_markup(self.format_stats(artist)) @@ -359,7 +358,6 @@ class ArtistDetailPanel(Gtk.Box): else: self.artist_artwork.get_style_context().add_class("collapsed") self.artist_name.get_style_context().add_class("collapsed") - self.artist_artwork.set_image_size(70) self.artist_indicator.hide() self.artist_stats.hide() self.artist_bio.hide() @@ -391,6 +389,11 @@ class ArtistDetailPanel(Gtk.Box): self.artist_artwork.set_from_file(cover_art_filename) self.artist_artwork.set_loading(False) + if self.artist_details_expanded: + self.artist_artwork.set_image_size(300) + else: + self.artist_artwork.set_image_size(70) + # Event Handlers # ========================================================================= def on_view_refresh_click(self, *args): diff --git a/sublime/ui/configure_servers.py b/sublime/ui/configure_servers.py index 55d42af..5637dcf 100644 --- a/sublime/ui/configure_servers.py +++ b/sublime/ui/configure_servers.py @@ -34,6 +34,7 @@ class EditServerDialog(EditFormDialog): super().__init__(*args, **kwargs) + # TODO figure out how to do this # def on_test_server_clicked(self, event: Any): # # Instantiate the server. # server_address = self.data["server_address"].get_text() diff --git a/sublime/ui/main.py b/sublime/ui/main.py index 0f8ec1a..8659d42 100644 --- a/sublime/ui/main.py +++ b/sublime/ui/main.py @@ -166,7 +166,7 @@ class MainWindow(Gtk.ApplicationWindow): if hasattr(active_panel, "update"): active_panel.update(app_config, force=force) - self.player_controls.update(app_config) + self.player_controls.update(app_config, force=force) def _create_stack(self, **kwargs: Gtk.Widget) -> Gtk.Stack: stack = Gtk.Stack() @@ -340,9 +340,8 @@ class MainWindow(Gtk.ApplicationWindow): # Clear Song File Cache menu_items = [ - ("Clear Song File Cache", lambda _: print("clear song file cache")), - ("Clear Metadata Cache", lambda _: print("clear metadata cache")), - ("Clear Entire Cache", lambda _: print("clear entire cache")), + ("Delete Cached Song Files", self._clear_song_file_cache), + ("Delete Cached Song Files and Metadata", self._clear_entire_cache), ] for text, clicked_fn in menu_items: clear_song_cache = self._create_model_button(text, clicked_fn) @@ -584,6 +583,40 @@ class MainWindow(Gtk.ApplicationWindow): return False + 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, + buttons=Gtk.ButtonsType.NONE, + text=title, + ) + confirm_dialog.add_buttons( + Gtk.STOCK_DELETE, + Gtk.ResponseType.YES, + Gtk.STOCK_CANCEL, + Gtk.ResponseType.CANCEL, + ) + confirm_dialog.format_secondary_markup(detail_text) + result = confirm_dialog.run() + confirm_dialog.destroy() + return result + + 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() + self.emit("refresh-window", {}, True) + + 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() + self.emit("refresh-window", {}, True) + def _on_downloads_menu_clicked(self, *args): self.downloads_popover.popup() self.downloads_popover.show_all() diff --git a/sublime/ui/player_controls.py b/sublime/ui/player_controls.py index ce6125e..8282387 100644 --- a/sublime/ui/player_controls.py +++ b/sublime/ui/player_controls.py @@ -63,7 +63,7 @@ class PlayerControls(Gtk.ActionBar): self.set_center_widget(playback_controls) self.pack_end(play_queue_volume) - def update(self, app_config: AppConfiguration): + def update(self, app_config: AppConfiguration, force: bool = False): self.current_device = app_config.state.current_device duration = ( @@ -176,7 +176,7 @@ class PlayerControls(Gtk.ActionBar): self.update_device_list() # Short circuit if no changes to the play queue - if ( + if not force and ( self.current_play_queue == app_config.state.play_queue and self.current_playing_index == app_config.state.current_song_index ): diff --git a/sublime/ui/playlists.py b/sublime/ui/playlists.py index 96f94f5..294c12b 100644 --- a/sublime/ui/playlists.py +++ b/sublime/ui/playlists.py @@ -484,7 +484,6 @@ class PlaylistDetailPanel(Gtk.Overlay): self.playlist_artwork.get_style_context().remove_class("collapsed") self.playlist_name.get_style_context().remove_class("collapsed") self.playlist_box.show_all() - self.playlist_artwork.set_image_size(200) self.playlist_indicator.set_markup("PLAYLIST") if playlist.comment: @@ -499,7 +498,6 @@ class PlaylistDetailPanel(Gtk.Overlay): self.playlist_artwork.get_style_context().add_class("collapsed") self.playlist_name.get_style_context().add_class("collapsed") self.playlist_box.show_all() - self.playlist_artwork.set_image_size(70) self.playlist_indicator.hide() self.playlist_comment.hide() self.playlist_stats.hide() @@ -576,6 +574,11 @@ class PlaylistDetailPanel(Gtk.Overlay): self.playlist_artwork.set_from_file(cover_art_filename) self.playlist_artwork.set_loading(False) + if self.playlist_details_expanded: + self.playlist_artwork.set_image_size(200) + else: + self.playlist_artwork.set_image_size(70) + # Event Handlers # ========================================================================= def on_view_refresh_click(self, _): From ea41326d1b1fba1e5e34fed2a5e3550a9f14e4c3 Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Sat, 23 May 2020 12:05:23 -0600 Subject: [PATCH 11/41] Wiring up the UI for offline mode --- sublime/adapters/manager.py | 3 ++ sublime/app.py | 7 +++-- sublime/ui/albums.py | 9 +++--- sublime/ui/artists.py | 31 ++++++++++++-------- sublime/ui/browse.py | 13 +++++++-- sublime/ui/common/album_with_songs.py | 14 +++++++-- sublime/ui/player_controls.py | 13 ++++++--- sublime/ui/playlists.py | 42 ++++++++++++++++----------- sublime/ui/util.py | 7 +++-- 9 files changed, 92 insertions(+), 47 deletions(-) diff --git a/sublime/adapters/manager.py b/sublime/adapters/manager.py index f4b1347..4dcc910 100644 --- a/sublime/adapters/manager.py +++ b/sublime/adapters/manager.py @@ -164,6 +164,7 @@ class AdapterManager: executor: ThreadPoolExecutor = ThreadPoolExecutor() download_executor: ThreadPoolExecutor = ThreadPoolExecutor() is_shutting_down: bool = False + offline_mode: bool = False @dataclass class _AdapterManagerInternal: @@ -233,6 +234,8 @@ class AdapterManager: if AdapterManager._instance: AdapterManager._instance.shutdown() + AdapterManager.offline_mode = config.offline_mode + # TODO (#197): actually do stuff with the config to determine which adapters to # create, etc. assert config.server is not None diff --git a/sublime/app.py b/sublime/app.py index a40fc36..521ac4e 100644 --- a/sublime/app.py +++ b/sublime/app.py @@ -260,9 +260,9 @@ class SublimeMusicApp(Gtk.Application): if self.exiting: return self.update_window() - GLib.timeout_add(5000, ping_update) + GLib.timeout_add(10000, ping_update) - GLib.timeout_add(5000, ping_update) + GLib.timeout_add(10000, ping_update) # Prompt to load the play queue from the server. if self.app_config.server.sync_enabled: @@ -505,6 +505,9 @@ class SublimeMusicApp(Gtk.Application): if settings := state_updates.get("__settings__"): for k, v in settings.items(): setattr(self.app_config, k, v) + if (offline_mode := settings.get("offline_mode")) is not None: + AdapterManager.offline_mode = offline_mode + del state_updates["__settings__"] for k, v in state_updates.items(): diff --git a/sublime/ui/albums.py b/sublime/ui/albums.py index 3f0617c..31bddc9 100644 --- a/sublime/ui/albums.py +++ b/sublime/ui/albums.py @@ -143,11 +143,11 @@ class AlbumsPanel(Gtk.Box): page_widget.add(self.next_page) actionbar.set_center_widget(page_widget) - refresh = IconButton( + self.refresh_button = IconButton( "view-refresh-symbolic", "Refresh list of albums", relief=True ) - refresh.connect("clicked", self.on_refresh_clicked) - actionbar.pack_end(refresh) + self.refresh_button.connect("clicked", self.on_refresh_clicked) + actionbar.pack_end(self.refresh_button) actionbar.pack_end(Gtk.Label(label="albums per page")) self.show_count_dropdown, _ = self.make_combobox( @@ -251,6 +251,7 @@ class AlbumsPanel(Gtk.Box): if app_config: self.album_page = app_config.state.album_page self.album_page_size = app_config.state.album_page_size + self.refresh_button.set_sensitive(not app_config.offline_mode) self.prev_page.set_sensitive(self.album_page > 0) self.page_entry.set_text(str(self.album_page + 1)) @@ -619,7 +620,7 @@ class AlbumsGrid(Gtk.Overlay): # Update the detail panel. children = self.detail_box_inner.get_children() if len(children) > 0 and hasattr(children[0], "update"): - children[0].update(force=force) + children[0].update(app_config=app_config, force=force) error_dialog = None diff --git a/sublime/ui/artists.py b/sublime/ui/artists.py index a43483d..a45cad9 100644 --- a/sublime/ui/artists.py +++ b/sublime/ui/artists.py @@ -64,9 +64,11 @@ class ArtistList(Gtk.Box): list_actions = Gtk.ActionBar() - refresh = IconButton("view-refresh-symbolic", "Refresh list of artists") - refresh.connect("clicked", lambda *a: self.update(force=True)) - list_actions.pack_end(refresh) + self.refresh_button = IconButton( + "view-refresh-symbolic", "Refresh list of artists" + ) + self.refresh_button.connect("clicked", lambda *a: self.update(force=True)) + list_actions.pack_end(self.refresh_button) self.add(list_actions) @@ -126,6 +128,7 @@ class ArtistList(Gtk.Box): ): if app_config: self._app_config = app_config + self.refresh_button.set_sensitive(not app_config.offline_mode) new_store = [] selected_idx = None @@ -250,15 +253,15 @@ class ArtistDetailPanel(Gtk.Box): orientation=Gtk.Orientation.HORIZONTAL, spacing=10 ) - download_all_btn = IconButton( + self.download_all_button = IconButton( "folder-download-symbolic", "Download all songs by this artist" ) - download_all_btn.connect("clicked", self.on_download_all_click) - self.artist_action_buttons.add(download_all_btn) + self.download_all_button.connect("clicked", self.on_download_all_click) + self.artist_action_buttons.add(self.download_all_button) - view_refresh_button = IconButton("view-refresh-symbolic", "Refresh artist info") - view_refresh_button.connect("clicked", self.on_view_refresh_click) - self.artist_action_buttons.add(view_refresh_button) + self.refresh_button = IconButton("view-refresh-symbolic", "Refresh artist info") + self.refresh_button.connect("clicked", self.on_view_refresh_click) + self.artist_action_buttons.add(self.refresh_button) action_buttons_container.pack_start( self.artist_action_buttons, False, False, 10 @@ -300,6 +303,8 @@ class ArtistDetailPanel(Gtk.Box): app_config=app_config, order_token=self.update_order_token, ) + self.refresh_button.set_sensitive(not app_config.offline_mode) + self.download_all_button.set_sensitive(not app_config.offline_mode) @util.async_callback( AdapterManager.get_artist, @@ -370,7 +375,7 @@ class ArtistDetailPanel(Gtk.Box): ) self.albums = artist.albums or [] - self.albums_list.update(artist) + self.albums_list.update(artist, app_config, force=force) @util.async_callback( AdapterManager.get_cover_art_filename, @@ -498,7 +503,9 @@ class AlbumsListWithSongs(Gtk.Overlay): self.albums = [] - def update(self, artist: API.Artist): + def update( + self, artist: API.Artist, app_config: AppConfiguration, force: bool = False + ): def remove_all(): for c in self.box.get_children(): self.box.remove(c) @@ -513,7 +520,7 @@ class AlbumsListWithSongs(Gtk.Overlay): if self.albums == new_albums: # Just go through all of the colidren and update them. for c in self.box.get_children(): - c.update() + c.update(app_config=app_config, force=force) self.spinner.hide() return diff --git a/sublime/ui/browse.py b/sublime/ui/browse.py index 9999c7f..3cd921e 100644 --- a/sublime/ui/browse.py +++ b/sublime/ui/browse.py @@ -170,6 +170,7 @@ class MusicDirectoryList(Gtk.Box): update_order_token = 0 directory_id: Optional[str] = None selected_id: Optional[str] = None + offline_mode = False class DrilldownElement(GObject.GObject): id = GObject.Property(type=str) @@ -185,9 +186,9 @@ class MusicDirectoryList(Gtk.Box): list_actions = Gtk.ActionBar() - refresh = IconButton("view-refresh-symbolic", "Refresh folder") - refresh.connect("clicked", lambda *a: self.update(force=True)) - list_actions.pack_end(refresh) + self.refresh_button = IconButton("view-refresh-symbolic", "Refresh folder") + self.refresh_button.connect("clicked", lambda *a: self.update(force=True)) + list_actions.pack_end(self.refresh_button) self.add(list_actions) @@ -251,6 +252,11 @@ class MusicDirectoryList(Gtk.Box): self.directory_id, force=force, order_token=self.update_order_token, ) + if app_config: + self.offline_mode = app_config.offline_mode + + self.refresh_button.set_sensitive(not self.offline_mode) + _current_child_ids: List[str] = [] @util.async_callback( @@ -412,6 +418,7 @@ class MusicDirectoryList(Gtk.Box): event.x, event.y + abs(bin_coords.by - widget_coords.wy), tree, + self.offline_mode, on_download_state_change=self.on_download_state_change, ) diff --git a/sublime/ui/common/album_with_songs.py b/sublime/ui/common/album_with_songs.py index 9d2369b..13acf62 100644 --- a/sublime/ui/common/album_with_songs.py +++ b/sublime/ui/common/album_with_songs.py @@ -21,6 +21,8 @@ class AlbumWithSongs(Gtk.Box): ), } + offline_mode = True + def __init__( self, album: API.Album, @@ -202,6 +204,7 @@ class AlbumWithSongs(Gtk.Box): event.x, event.y + abs(bin_coords.by - widget_coords.wy), tree, + self.offline_mode, on_download_state_change=on_download_state_change, ) @@ -238,8 +241,11 @@ class AlbumWithSongs(Gtk.Box): def deselect_all(self): self.album_songs.get_selection().unselect_all() - def update(self, force: bool = False): - self.update_album_songs(self.album.id, force=force) + def update(self, app_config: AppConfiguration = None, force: bool = False): + if app_config: + self.offline_mode = app_config.offline_mode + + self.update_album_songs(self.album.id, app_config=app_config, force=force) def set_loading(self, loading: bool): if loading: @@ -283,7 +289,9 @@ class AlbumWithSongs(Gtk.Box): self.play_btn.set_sensitive(True) self.shuffle_btn.set_sensitive(True) - self.download_all_btn.set_sensitive(AdapterManager.can_batch_download_songs()) + self.download_all_btn.set_sensitive( + not self.offline_mode and AdapterManager.can_batch_download_songs() + ) self.play_next_btn.set_action_target_value(GLib.Variant("as", song_ids)) self.add_to_queue_btn.set_action_target_value(GLib.Variant("as", song_ids)) diff --git a/sublime/ui/player_controls.py b/sublime/ui/player_controls.py index 8282387..2c83b37 100644 --- a/sublime/ui/player_controls.py +++ b/sublime/ui/player_controls.py @@ -48,6 +48,7 @@ class PlayerControls(Gtk.ActionBar): cover_art_update_order_token = 0 play_queue_update_order_token = 0 devices_requested = False + offline_mode = False def __init__(self): Gtk.ActionBar.__init__(self) @@ -176,6 +177,9 @@ class PlayerControls(Gtk.ActionBar): self.update_device_list() # Short circuit if no changes to the play queue + self.offline_mode = app_config.offline_mode + self.load_play_queue_button.set_sensitive(not self.offline_mode) + if not force and ( self.current_play_queue == app_config.state.play_queue and self.current_playing_index == app_config.state.current_song_index @@ -422,7 +426,7 @@ class PlayerControls(Gtk.ActionBar): def on_device_refresh_click(self, _: Any): self.update_device_list(force=True) - def on_play_queue_button_press(self, tree: Any, event: Gdk.EventButton,) -> bool: + def on_play_queue_button_press(self, tree: Any, event: Gdk.EventButton) -> bool: if event.button == 3: # Right click clicked_path = tree.get_path_at_pos(event.x, event.y) @@ -454,6 +458,7 @@ class PlayerControls(Gtk.ActionBar): event.x, event.y, tree, + self.offline_mode, on_download_state_change=on_download_state_change, extra_menu_items=[ (Gtk.ModelButton(text=remove_text), on_remove_songs_click), @@ -688,11 +693,11 @@ class PlayerControls(Gtk.ActionBar): ) play_queue_popover_header.add(self.popover_label) - load_play_queue = IconButton( + self.load_play_queue_button = IconButton( "folder-download-symbolic", "Load Queue from Server", margin=5 ) - load_play_queue.set_action_name("app.update-play-queue-from-server") - play_queue_popover_header.pack_end(load_play_queue, False, False, 0) + self.load_play_queue_button.set_action_name("app.update-play-queue-from-server") + play_queue_popover_header.pack_end(self.load_play_queue_button, False, False, 0) play_queue_popover_box.add(play_queue_popover_header) diff --git a/sublime/ui/playlists.py b/sublime/ui/playlists.py index 294c12b..92a6abd 100644 --- a/sublime/ui/playlists.py +++ b/sublime/ui/playlists.py @@ -87,15 +87,15 @@ class PlaylistList(Gtk.Box): playlist_list_actions = Gtk.ActionBar() - new_playlist_button = IconButton("list-add-symbolic", label="New Playlist") - new_playlist_button.connect("clicked", self.on_new_playlist_clicked) - playlist_list_actions.pack_start(new_playlist_button) + self.new_playlist_button = IconButton("list-add-symbolic", label="New Playlist") + self.new_playlist_button.connect("clicked", self.on_new_playlist_clicked) + playlist_list_actions.pack_start(self.new_playlist_button) - list_refresh_button = IconButton( + self.list_refresh_button = IconButton( "view-refresh-symbolic", "Refresh list of playlists" ) - list_refresh_button.connect("clicked", self.on_list_refresh_click) - playlist_list_actions.pack_end(list_refresh_button) + self.list_refresh_button.connect("clicked", self.on_list_refresh_click) + playlist_list_actions.pack_end(self.list_refresh_button) self.add(playlist_list_actions) @@ -164,9 +164,11 @@ class PlaylistList(Gtk.Box): list_scroll_window.add(self.list) self.pack_start(list_scroll_window, True, True, 0) - def update(self, **kwargs): + def update(self, app_config: AppConfiguration, force: bool = False): + self.new_playlist_button.set_sensitive(not app_config.offline_mode) + self.list_refresh_button.set_sensitive(not app_config.offline_mode) self.new_playlist_row.hide() - self.update_list(**kwargs) + self.update_list(app_config=app_config, force=force) @util.async_callback( AdapterManager.get_playlists, @@ -247,6 +249,7 @@ class PlaylistDetailPanel(Gtk.Overlay): playlist_id = None playlist_details_expanded = False + offline_mode = False editing_playlist_song_list: bool = False reordering_playlist_song_list: bool = False @@ -308,23 +311,23 @@ class PlaylistDetailPanel(Gtk.Overlay): orientation=Gtk.Orientation.HORIZONTAL, spacing=10 ) - download_all_button = IconButton( + self.download_all_button = IconButton( "folder-download-symbolic", "Download all songs in the playlist" ) - download_all_button.connect( + self.download_all_button.connect( "clicked", self.on_playlist_list_download_all_button_click ) - self.playlist_action_buttons.add(download_all_button) + self.playlist_action_buttons.add(self.download_all_button) - playlist_edit_button = IconButton("document-edit-symbolic", "Edit paylist") - playlist_edit_button.connect("clicked", self.on_playlist_edit_button_click) - self.playlist_action_buttons.add(playlist_edit_button) + self.playlist_edit_button = IconButton("document-edit-symbolic", "Edit paylist") + self.playlist_edit_button.connect("clicked", self.on_playlist_edit_button_click) + self.playlist_action_buttons.add(self.playlist_edit_button) - view_refresh_button = IconButton( + self.view_refresh_button = IconButton( "view-refresh-symbolic", "Refresh playlist info" ) - view_refresh_button.connect("clicked", self.on_view_refresh_click) - self.playlist_action_buttons.add(view_refresh_button) + self.view_refresh_button.connect("clicked", self.on_view_refresh_click) + self.playlist_action_buttons.add(self.view_refresh_button) action_buttons_container.pack_start( self.playlist_action_buttons, False, False, 10 @@ -430,6 +433,7 @@ class PlaylistDetailPanel(Gtk.Overlay): update_playlist_view_order_token = 0 def update(self, app_config: AppConfiguration, force: bool = False): + self.offline_mode = app_config.offline_mode if app_config.state.selected_playlist_id is None: self.playlist_box.hide() self.playlist_view_loading_box.hide() @@ -442,6 +446,9 @@ class PlaylistDetailPanel(Gtk.Overlay): force=force, order_token=self.update_playlist_view_order_token, ) + self.download_all_button.set_sensitive(not app_config.offline_mode) + self.playlist_edit_button.set_sensitive(not app_config.offline_mode) + self.view_refresh_button.set_sensitive(not app_config.offline_mode) _current_song_ids: List[str] = [] @@ -746,6 +753,7 @@ class PlaylistDetailPanel(Gtk.Overlay): event.x, event.y + abs(bin_coords.by - widget_coords.wy), tree, + self.offline_mode, on_download_state_change=on_download_state_change, extra_menu_items=[ (Gtk.ModelButton(text=remove_text), on_remove_songs_click), diff --git a/sublime/ui/util.py b/sublime/ui/util.py index e11c4ef..e4ef3b4 100644 --- a/sublime/ui/util.py +++ b/sublime/ui/util.py @@ -187,6 +187,7 @@ def show_song_popover( x: int, y: int, relative_to: Any, + offline_mode: bool, position: Gtk.PositionType = Gtk.PositionType.BOTTOM, on_download_state_change: Callable[[str], None] = lambda _: None, on_playlist_state_change: Callable[[], None] = lambda: None, @@ -236,13 +237,15 @@ def show_song_popover( # Retrieve songs and set the buttons as sensitive later. def on_get_song_details_done(songs: List[Song]): song_cache_statuses = AdapterManager.get_cached_statuses([s.id for s in songs]) - if any(status == SongCacheStatus.NOT_CACHED for status in song_cache_statuses): + if not offline_mode and any( + status == SongCacheStatus.NOT_CACHED for status in song_cache_statuses + ): download_song_button.set_sensitive(True) if any( status in (SongCacheStatus.CACHED, SongCacheStatus.PERMANENTLY_CACHED) for status in song_cache_statuses ): - download_song_button.set_sensitive(True) + remove_download_button.set_sensitive(True) albums, artists, parents = set(), set(), set() for song in songs: From b1d1f35c1ff6b092454fdd7a7589d79ce0fb7141 Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Sat, 23 May 2020 13:12:23 -0600 Subject: [PATCH 12/41] Starting to pipe offline mode through the adapter manager --- sublime/adapters/manager.py | 54 ++++++++++++++++++++++++++++++------- sublime/ui/main.py | 2 +- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/sublime/adapters/manager.py b/sublime/adapters/manager.py index 4dcc910..2c9b1dc 100644 --- a/sublime/adapters/manager.py +++ b/sublime/adapters/manager.py @@ -290,9 +290,13 @@ class AdapterManager: @staticmethod def _ground_truth_can_do(action_name: str) -> bool: - return AdapterManager._instance is not None and AdapterManager._adapter_can_do( - AdapterManager._instance.ground_truth_adapter, action_name - ) + if not AdapterManager._instance: + return False + ground_truth_adapter = AdapterManager._instance.ground_truth_adapter + if AdapterManager.offline_mode and ground_truth_adapter.is_networked: + return False + + return AdapterManager._adapter_can_do(ground_truth_adapter, action_name) @staticmethod def _can_use_cache(force: bool, action_name: str) -> bool: @@ -319,6 +323,14 @@ class AdapterManager: """ Creates a Result using the given ``function_name`` on the ground truth adapter. """ + if ( + AdapterManager.offline_mode + and AdapterManager._instance + and AdapterManager._instance.ground_truth_adapter.is_networked + ): + raise AssertionError( + "You should never call _create_ground_truth_result in offline mode" + ) def future_fn() -> Any: assert AdapterManager._instance @@ -338,6 +350,14 @@ class AdapterManager: filename. The returned function will spin-loop if the resource is already being downloaded to prevent multiple requests for the same download. """ + if ( + AdapterManager.offline_mode + and AdapterManager._instance + and AdapterManager._instance.ground_truth_adapter.is_networked + ): + raise AssertionError( + "You should never call _create_download_fn in offline mode" + ) def download_fn() -> str: assert AdapterManager._instance @@ -679,9 +699,15 @@ class AdapterManager: @staticmethod def delete_playlist(playlist_id: str): - # TODO (#190): make non-blocking? assert AdapterManager._instance - AdapterManager._instance.ground_truth_adapter.delete_playlist(playlist_id) + ground_truth_adapter = AdapterManager._instance.ground_truth_adapter + if AdapterManager.offline_mode and ground_truth_adapter.is_networked: + raise AssertionError( + "You should never call _create_download_fn in offline mode" + ) + + # TODO (#190): make non-blocking? + ground_truth_adapter.delete_playlist(playlist_id) if AdapterManager._instance.caching_adapter: AdapterManager._instance.caching_adapter.delete_data( @@ -697,6 +723,7 @@ class AdapterManager: not AdapterManager._ground_truth_can_do("get_cover_art_uri") or not cover_art_id ): + # TODO return the placeholder return "" return AdapterManager._instance.ground_truth_adapter.get_cover_art_uri( @@ -796,6 +823,7 @@ class AdapterManager: if not AdapterManager._ground_truth_can_do("get_song_uri"): if not allow_song_downloads or cached_song_filename is None: + # TODO raise Exception("Can't stream the song.") return cached_song_filename @@ -806,6 +834,7 @@ class AdapterManager: if not allow_song_downloads and not AdapterManager._ground_truth_can_do( "stream" ): + # TODO raise Exception("Can't stream the song.") return AdapterManager._instance.ground_truth_adapter.get_song_uri( @@ -821,6 +850,13 @@ class AdapterManager: delay: float = 0.0, ) -> Result[None]: assert AdapterManager._instance + if ( + AdapterManager.offline_mode + and AdapterManager._instance.ground_truth_adapter.is_networked + ): + raise AssertionError( + "You should never call batch_download_songs in offline mode" + ) # This only really makes sense if we have a caching_adapter. if not AdapterManager._instance.caching_adapter: @@ -1151,7 +1187,7 @@ class AdapterManager: sleep(0.3) if cancelled: logging.info(f"Cancelled query {query} before caching adapter") - return False + return True assert AdapterManager._instance @@ -1176,11 +1212,11 @@ class AdapterManager: # Wait longer to see if the user types anything else so we don't peg the # server with tons of requests. sleep( - 1 if AdapterManager._instance.ground_truth_adapter.is_networked else 0.2 + 1 if AdapterManager._instance.ground_truth_adapter.is_networked else 0.3 ) if cancelled: logging.info(f"Cancelled query {query} before server results") - return False + return True try: ground_truth_search_results = AdapterManager._instance.ground_truth_adapter.search( # noqa: E501 @@ -1200,7 +1236,7 @@ class AdapterManager: ground_truth_search_results, ) - return True + return False # When the future is cancelled (this will happen if a new search is created), # set cancelled to True so that the search function can abort. diff --git a/sublime/ui/main.py b/sublime/ui/main.py index 8659d42..31cf4f5 100644 --- a/sublime/ui/main.py +++ b/sublime/ui/main.py @@ -664,7 +664,7 @@ class MainWindow(Gtk.ApplicationWindow): GLib.idle_add(self._update_search_results, result) def search_result_done(r: Result): - if not r.result(): + if r.result() is True: # The search was cancelled return From 57bb939664412fa98a502a811321821925ed3884 Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Sat, 23 May 2020 13:14:51 -0600 Subject: [PATCH 13/41] Upgrade dataclasse-json --- Pipfile | 1 - Pipfile.lock | 74 ++++++++++++++++---------------- flatpak/flatpak-requirements.txt | 2 +- setup.py | 2 +- 4 files changed, 40 insertions(+), 39 deletions(-) diff --git a/Pipfile b/Pipfile index ef15662..7e21f05 100644 --- a/Pipfile +++ b/Pipfile @@ -27,7 +27,6 @@ termcolor = "*" [packages] sublime-music = {editable = true,extras = ["keyring"],path = "."} -dataclasses-json = {editable = true,git = "https://github.com/lidatong/dataclasses-json",ref = "master"} [requires] python_version = "3.8" diff --git a/Pipfile.lock b/Pipfile.lock index cf9d6df..40b733d 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "c72a092370d49b350cf6565988c36a58e5e80cf0f322a07e0f0eaa6cffe2f39f" + "sha256": "a04cebbd47f79d5d7fa2266238fde4b5530a0271d5bb2a514e579a1eed3632f6" }, "pipfile-spec": 6, "requires": { @@ -102,9 +102,11 @@ "version": "==2.9.2" }, "dataclasses-json": { - "editable": true, - "git": "https://github.com/lidatong/dataclasses-json", - "ref": "b8b60cdaa2c3ccc8d3bcbce67e911b705c3b0b10" + "hashes": [ + "sha256:175a30bdbd10d85022bb8684c7e0749217547d842692b2617f982ce197ab6121", + "sha256:6c022dc5598162972253c197a3af16d08c0f9eb30630da383ac165a3903a4d11" + ], + "version": "==0.4.3" }, "deepdiff": { "hashes": [ @@ -183,24 +185,24 @@ }, "protobuf": { "hashes": [ - "sha256:00c2c276aca3af220d422e6a8625b1f5399c821c9b6f1c83e8a535aa8f48cc6c", - "sha256:0d69d76b00d0eb5124cb33a34a793383a5bbbf9ac3e633207c09988717c5da85", - "sha256:1c55277377dd35e508e9d86c67a545f6d8d242d792af487678eeb75c07974ee2", - "sha256:35bc1b96241b8ea66dbf386547ef2e042d73dcc0bf4b63566e3ef68722bb24d1", - "sha256:47a541ac44f2dcc8d49b615bcf3ed7ba4f33af9791118cecc3d17815fab652d9", - "sha256:61364bcd2d85277ab6155bb7c5267e6a64786a919f1a991e29eb536aa5330a3d", - "sha256:7aaa820d629f8a196763dd5ba21fd272fa038f775a845a52e21fa67862abcd35", - "sha256:9593a6cdfc491f2caf62adb1c03170e9e8748d0a69faa2b3970e39a92fbd05a2", - "sha256:95f035bbafec7dbaa0f1c72eda8108b763c1671fcb6e577e93da2d52eb47fbcf", - "sha256:9d6a517ce33cbdc64b52a17c56ce17b0b20679c945ed7420e7c6bc6686ff0494", - "sha256:a7532d971e4ab2019a9f6aa224b209756b6b9e702940ca85a4b1ed1d03f45396", - "sha256:b4e8ecb1eb3d011f0ccc13f8bb0a2d481aa05b733e6e22e9d46a3f61dbbef0de", - "sha256:bb1aced9dcebc46f0b320f24222cc8ffdfd2e47d2bafd4d2e5913cc6f7e3fc98", - "sha256:ccce142ebcfbc35643a5012cf398497eb18e8d021333cced4d5401f034a8cef5", - "sha256:d538eecc0b80accfb73c8167f39aaa167a5a50f31b1295244578c8eff8e9d602", - "sha256:eab18765eb5c7bad1b2de7ae3774192b46e1873011682e36bcd70ccf75f2748a" + "sha256:04d0b2bd99050d09393875a5a25fd12337b17f3ac2e29c0c1b8e65b277cbfe72", + "sha256:05288e44638e91498f13127a3699a6528dec6f9d3084d60959d721bfb9ea5b98", + "sha256:175d85370947f89e33b3da93f4ccdda3f326bebe3e599df5915ceb7f804cd9df", + "sha256:440a8c77531b3652f24999b249256ed01fd44c498ab0973843066681bd276685", + "sha256:49fb6fab19cd3f30fa0e976eeedcbf2558e9061e5fa65b4fe51ded1f4002e04d", + "sha256:4c7cae1f56056a4a2a2e3b00b26ab8550eae738bd9548f4ea0c2fcb88ed76ae5", + "sha256:519abfacbb421c3591d26e8daf7a4957763428db7267f7207e3693e29f6978db", + "sha256:60f32af25620abc4d7928d8197f9f25d49d558c5959aa1e08c686f974ac0b71a", + "sha256:613ac49f6db266fba243daf60fb32af107cfe3678e5c003bb40a381b6786389d", + "sha256:954bb14816edd24e746ba1a6b2d48c43576393bbde2fb8e1e3bd6d4504c7feac", + "sha256:9b1462c033a2cee7f4e8eb396905c69de2c532c3b835ff8f71f8e5fb77c38023", + "sha256:c0767f4d93ce4288475afe0571663c78870924f1f8881efd5406c10f070c75e4", + "sha256:c45f5980ce32879391144b5766120fd7b8803129f127ce36bd060dd38824801f", + "sha256:eeb7502f59e889a88bcb59f299493e215d1864f3d75335ea04a413004eb4fe24", + "sha256:fdb1742f883ee4662e39fcc5916f2725fec36a5191a52123fec60f8c53b70495", + "sha256:fe554066c4962c2db0a1d4752655223eb948d2bfa0fb1c4a7f2c00ec07324f1c" ], - "version": "==3.12.0" + "version": "==3.12.1" }, "pycairo": { "hashes": [ @@ -280,10 +282,10 @@ }, "six": { "hashes": [ - "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a", - "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c" + "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", + "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], - "version": "==1.14.0" + "version": "==1.15.0" }, "stringcase": { "hashes": [ @@ -323,10 +325,10 @@ }, "zeroconf": { "hashes": [ - "sha256:51f25787c27cf7b903e6795e8763bccdaa71199f61b75af97f1bde036fa43b27", - "sha256:a0cdd43ee8f00e7082f784c4226d2609070ad0b2aeb34b0154466950d2134de6" + "sha256:569c801e50891e0cc639c223e296e870dd9f6242a4f2b41d356666735b2a4264", + "sha256:bca127caa7d16217cbca78290dbee532b41d71e798b939548dc5a2c3a8f98e5e" ], - "version": "==0.26.1" + "version": "==0.26.2" } }, "develop": { @@ -433,11 +435,11 @@ }, "flake8": { "hashes": [ - "sha256:6c1193b0c3f853ef763969238f6c81e9e63ace9d024518edc020d5f1d6d93195", - "sha256:ea6623797bf9a52f4c9577d780da0bb17d65f870213f7b5bcc9fca82540c31d5" + "sha256:c69ac1668e434d37a2d2880b3ca9aafd54b3a10a3ac1ab101d22f29e29cf8634", + "sha256:ccaa799ef9893cebe69fdfefed76865aeaefbb94cb8545617b2298786a4de9a5" ], "index": "pypi", - "version": "==3.8.1" + "version": "==3.8.2" }, "flake8-annotations": { "hashes": [ @@ -690,11 +692,11 @@ }, "pytest-cov": { "hashes": [ - "sha256:cc6742d8bac45070217169f5f72ceee1e0e55b0221f54bcf24845972d3a47f2b", - "sha256:cdbdef4f870408ebdbfeb44e63e07eb18bb4619fae852f6e760645fa36172626" + "sha256:b6a814b8ed6247bd81ff47f038511b57fe1ce7f4cc25b9106f1a4b106f1d9322", + "sha256:c87dfd8465d865655a8213859f1b4749b43448b5fae465cb981e16d52a811424" ], "index": "pypi", - "version": "==2.8.1" + "version": "==2.9.0" }, "pytz": { "hashes": [ @@ -752,10 +754,10 @@ }, "six": { "hashes": [ - "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a", - "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c" + "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", + "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], - "version": "==1.14.0" + "version": "==1.15.0" }, "snowballstemmer": { "hashes": [ diff --git a/flatpak/flatpak-requirements.txt b/flatpak/flatpak-requirements.txt index b3acdb8..6be8d72 100644 --- a/flatpak/flatpak-requirements.txt +++ b/flatpak/flatpak-requirements.txt @@ -1,5 +1,5 @@ bottle==0.12.18 -git+https://github.com/lidatong/dataclasses-json@b8b60cdaa2c3ccc8d3bcbce67e911b705c3b0b10#egg=dataclasses-json +dataclasses-json==0.4.3 deepdiff==4.3.2 fuzzywuzzy==0.18.0 peewee==3.13.3 diff --git a/setup.py b/setup.py index 949f076..d49dfdb 100644 --- a/setup.py +++ b/setup.py @@ -53,7 +53,7 @@ setup( }, install_requires=[ "bottle", - "dataclasses-json @ git+https://github.com/lidatong/dataclasses-json@master#egg=dataclasses-json", # noqa: E501 + "dataclasses-json", "deepdiff", "fuzzywuzzy", 'osxmmkeys ; sys_platform=="darwin"', From dbb86350db60b0fd9dbf04e57a2652a714e32a04 Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Sat, 23 May 2020 18:09:03 -0600 Subject: [PATCH 14/41] Closes #211: fixed issue where you couldn't create a playlist with a space in the name --- sublime/adapters/subsonic/adapter.py | 2 +- sublime/app.py | 6 +++++- sublime/ui/main.py | 12 ++++++++---- sublime/ui/playlists.py | 11 ++++++----- 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/sublime/adapters/subsonic/adapter.py b/sublime/adapters/subsonic/adapter.py index c0a3ab5..8c8a5fd 100644 --- a/sublime/adapters/subsonic/adapter.py +++ b/sublime/adapters/subsonic/adapter.py @@ -335,7 +335,7 @@ class SubsonicAdapter(Adapter): # ================================================================================== def get_playlists(self) -> Sequence[API.Playlist]: if playlists := self._get_json(self._make_url("getPlaylists")).playlists: - return playlists.playlist + return sorted(playlists.playlist, key=lambda p: p.name.lower()) return [] def get_playlist_details(self, playlist_id: str) -> API.Playlist: diff --git a/sublime/app.py b/sublime/app.py index 521ac4e..5918574 100644 --- a/sublime/app.py +++ b/sublime/app.py @@ -816,16 +816,20 @@ class SublimeMusicApp(Gtk.Application): self.player.volume = self.app_config.state.volume self.update_window() - def on_window_key_press(self, window: Gtk.Window, event: Gdk.EventKey,) -> bool: + def on_window_key_press(self, window: Gtk.Window, event: Gdk.EventKey) -> bool: # Need to use bitwise & here to see if CTRL is pressed. if event.keyval == 102 and event.state & Gdk.ModifierType.CONTROL_MASK: # Ctrl + F window.search_entry.grab_focus() return False + # Allow spaces to work in the text entry boxes. if window.search_entry.has_focus(): return False + if window.playlists_panel.playlist_list.new_playlist_entry.has_focus(): + return False + # Spacebar, home/prev keymap = { 32: self.on_play_pause, 65360: self.on_prev_track, diff --git a/sublime/ui/main.py b/sublime/ui/main.py index 31cf4f5..aa9041b 100644 --- a/sublime/ui/main.py +++ b/sublime/ui/main.py @@ -35,11 +35,15 @@ class MainWindow(Gtk.ApplicationWindow): self.set_default_size(1150, 768) # Create the stack + self.albums_panel = albums.AlbumsPanel() + self.artists_panel = artists.ArtistsPanel() + self.browse_panel = browse.BrowsePanel() + self.playlists_panel = playlists.PlaylistsPanel() self.stack = self._create_stack( - Albums=albums.AlbumsPanel(), - Artists=artists.ArtistsPanel(), - Browse=browse.BrowsePanel(), - Playlists=playlists.PlaylistsPanel(), + Albums=self.albums_panel, + Artists=self.artists_panel, + Browse=self.browse_panel, + Playlists=self.playlists_panel, ) self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT_RIGHT) diff --git a/sublime/ui/playlists.py b/sublime/ui/playlists.py index 92a6abd..6744c20 100644 --- a/sublime/ui/playlists.py +++ b/sublime/ui/playlists.py @@ -164,9 +164,10 @@ class PlaylistList(Gtk.Box): list_scroll_window.add(self.list) self.pack_start(list_scroll_window, True, True, 0) - def update(self, app_config: AppConfiguration, force: bool = False): - self.new_playlist_button.set_sensitive(not app_config.offline_mode) - self.list_refresh_button.set_sensitive(not app_config.offline_mode) + def update(self, app_config: AppConfiguration = None, force: bool = False): + if app_config: + self.new_playlist_button.set_sensitive(not app_config.offline_mode) + self.list_refresh_button.set_sensitive(not app_config.offline_mode) self.new_playlist_row.hide() self.update_list(app_config=app_config, force=force) @@ -178,7 +179,7 @@ class PlaylistList(Gtk.Box): def update_list( self, playlists: List[API.Playlist], - app_config: AppConfiguration, + app_config: AppConfiguration = None, force: bool = False, order_token: int = None, ): @@ -626,7 +627,7 @@ class PlaylistDetailPanel(Gtk.Overlay): Gtk.ResponseType.CANCEL, ) confirm_dialog.format_secondary_markup( - 'Are you sure you want to delete the "{playlist.name}" playlist?' + f'Are you sure you want to delete the "{playlist.name}" playlist?' ) result = confirm_dialog.run() confirm_dialog.destroy() From ac6bcec89c4cc7ef7c1f6f82d6bcbf54479cf3df Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Sat, 23 May 2020 20:34:44 -0600 Subject: [PATCH 15/41] Update CHANGELOG --- CHANGELOG.rst | 50 ++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 40 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d7c88e0..19ae5ba 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,29 +1,59 @@ v0.9.3 ====== -* **Features** +.. warning:: + + This version is not compatible with any previous versions. If you have run a + previous version of Sublime Music, please delete your cache (likely in + ``~/.local/share/sublime-music``) and your existing configuration (likely in + ``~/.config/sublime-music``) and re-run Sublime Music to restart the + configuration process. + +**Note:** this release does not have Flatpak support due to the fact that +Flatpak does not support Python 3.8 yet. + +* **UI Features** + + * **Albums Tab** * The Albums tab is now paginated with configurable page sizes. * You can sort the Albums tab ascending or descending. * Opening an closing an album on the Albums tab now has a nice animation. + + * **Player Controls** + * The amount of the song that is cached is now shown while streaming a song. * The notification for resuming a play queue is now a non-modal notification that pops up right above the player controls. - * **New Icons** - * The Devices button now uses the Chromecast logo - * Custom icons for "Add to play queue", and "Play next" buttons. Thanks to - @samsartor for contributing the SVGs! - * A new icon for the Subsonic adapter. Contributed by @samsartor. + * **New Icons** - * Settings has gotten a revamp + * The Devices button now uses the Chromecast logo. + * Custom icons for "Add to play queue", and "Play next" buttons. Thanks to + @samsartor for contributing the SVGs! + * A new icon for indicating the connection state to the Subsonic server. + Contributed by @samsartor. -* This release has a ton of under-the-hood changes to make things more robust + * **Settings** + + * Settings are now in the popup under the gear icon rather than in a + separate popup window. + * You can now clear the cache via an option in the Downloads popup. There + are options for removing the entire cache and removing just the song file + cache. + +* **Backend** + + This release has a ton of under-the-hood changes to make things more robust and performant. * The cache is now stored in a SQLite database. - * The cache is no longer reliant on Subsonic which will enable more backends - in the future. + * The cache no longer gets corrupted when Sublime Music fails to write to + disk. + * A generic `Adapter API`_ has been created which means that Sublime Music is + no longer reliant on Subsonic and in the future, more backends can be added. + +.. _Adapter API: https://sumner.gitlab.io/sublime-music/adapter-api.html v0.9.2 ====== From 654b0902e79245c8a28fc93caff439d750e3f4d6 Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Sun, 24 May 2020 19:14:33 -0600 Subject: [PATCH 16/41] More UI fixes for offline mode --- CHANGELOG.rst | 59 ++++++++------- sublime-music.metainfo.xml | 3 +- sublime/__init__.py | 2 +- sublime/adapters/filesystem/adapter.py | 16 +++-- sublime/adapters/subsonic/adapter.py | 1 + sublime/app.py | 7 +- sublime/ui/albums.py | 4 +- sublime/ui/app_styles.css | 9 +++ sublime/ui/artists.py | 15 +++- sublime/ui/browse.py | 36 +++++++--- sublime/ui/common/album_with_songs.py | 67 ++++++++++------- sublime/ui/common/song_list_column.py | 2 +- sublime/ui/images/play-queue-play.svg | 99 +------------------------- sublime/ui/main.py | 1 + sublime/ui/playlists.py | 92 +++++++++++++++--------- sublime/ui/util.py | 88 ++++++++++++----------- 16 files changed, 257 insertions(+), 244 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 19ae5ba..9b8fc20 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,5 +1,5 @@ -v0.9.3 -====== +v0.10.0 +======= .. warning:: @@ -9,40 +9,47 @@ v0.9.3 ``~/.config/sublime-music``) and re-run Sublime Music to restart the configuration process. -**Note:** this release does not have Flatpak support due to the fact that -Flatpak does not support Python 3.8 yet. +Features +-------- -* **UI Features** +**Albums Tab Improvements** - * **Albums Tab** +* The Albums tab is now paginated with configurable page sizes. +* You can sort the Albums tab ascending or descending. +* Opening an closing an album on the Albums tab now has a nice animation. - * The Albums tab is now paginated with configurable page sizes. - * You can sort the Albums tab ascending or descending. - * Opening an closing an album on the Albums tab now has a nice animation. +**Player Controls** - * **Player Controls** +* The amount of the song that is cached is now shown while streaming a song. +* The notification for resuming a play queue is now a non-modal notification + that pops up right above the player controls. - * The amount of the song that is cached is now shown while streaming a song. - * The notification for resuming a play queue is now a non-modal - notification that pops up right above the player controls. +**New Icons** - * **New Icons** +* The Devices button now uses the Chromecast logo. +* Custom icons for "Add to play queue", and "Play next" buttons. Thanks to + @samsartor for contributing the SVGs! +* A new icon for indicating the connection state to the Subsonic server. + Contributed by @samsartor. - * The Devices button now uses the Chromecast logo. - * Custom icons for "Add to play queue", and "Play next" buttons. Thanks to - @samsartor for contributing the SVGs! - * A new icon for indicating the connection state to the Subsonic server. - Contributed by @samsartor. +**Settings** - * **Settings** +* Settings are now in the popup under the gear icon rather than in a separate + popup window. +* The music provider configuration has gotten a major revamp. +* You can now clear the cache via an option in the Downloads popup. There are + options for removing the entire cache and removing just the song file cache. - * Settings are now in the popup under the gear icon rather than in a - separate popup window. - * You can now clear the cache via an option in the Downloads popup. There - are options for removing the entire cache and removing just the song file - cache. +**Offline Mode** -* **Backend** +* You can enable *Offline Mode* from the server menu. +* Features that require network access are disabled in offline mode. +* You can still browse anything that is already cached offline. + +.. MENTION man page + +Under The Hood +-------------- This release has a ton of under-the-hood changes to make things more robust and performant. diff --git a/sublime-music.metainfo.xml b/sublime-music.metainfo.xml index ce11ff9..47270f0 100644 --- a/sublime-music.metainfo.xml +++ b/sublime-music.metainfo.xml @@ -77,7 +77,6 @@ me_AT_sumnerevans.com - - + diff --git a/sublime/__init__.py b/sublime/__init__.py index a2fecb4..61fb31c 100644 --- a/sublime/__init__.py +++ b/sublime/__init__.py @@ -1 +1 @@ -__version__ = "0.9.2" +__version__ = "0.10.0" diff --git a/sublime/adapters/filesystem/adapter.py b/sublime/adapters/filesystem/adapter.py index 8c9b52e..736a2eb 100644 --- a/sublime/adapters/filesystem/adapter.py +++ b/sublime/adapters/filesystem/adapter.py @@ -251,9 +251,11 @@ class FilesystemAdapter(CachingAdapter): ) if cover_art: filename = self.cover_art_dir.joinpath(str(cover_art.file_hash)) - if cover_art.valid and filename.exists(): - return str(filename) - raise CacheMissError(partial_data=str(filename)) + if filename.exists(): + if cover_art.valid: + return str(filename) + else: + raise CacheMissError(partial_data=str(filename)) raise CacheMissError() @@ -269,9 +271,11 @@ class FilesystemAdapter(CachingAdapter): if (song_file := song.file) and ( filename := self._compute_song_filename(song_file) ): - if song_file.valid and filename.exists(): - return str(filename) - raise CacheMissError(partial_data=str(filename)) + if filename.exists(): + if song_file.valid: + return str(filename) + else: + raise CacheMissError(partial_data=str(filename)) except models.CacheInfo.DoesNotExist: pass diff --git a/sublime/adapters/subsonic/adapter.py b/sublime/adapters/subsonic/adapter.py index 8c8a5fd..c8f1a3b 100644 --- a/sublime/adapters/subsonic/adapter.py +++ b/sublime/adapters/subsonic/adapter.py @@ -152,6 +152,7 @@ class SubsonicAdapter(Adapter): sleep(15) def _set_ping_status(self): + # TODO don't ping in offline mode try: # Try to ping the server with a timeout of 2 seconds. self._get_json(self._make_url("ping"), timeout=2) diff --git a/sublime/app.py b/sublime/app.py index 5918574..d086e94 100644 --- a/sublime/app.py +++ b/sublime/app.py @@ -824,9 +824,10 @@ class SublimeMusicApp(Gtk.Application): return False # Allow spaces to work in the text entry boxes. - if window.search_entry.has_focus(): - return False - if window.playlists_panel.playlist_list.new_playlist_entry.has_focus(): + if ( + window.search_entry.has_focus() + or window.playlists_panel.playlist_list.new_playlist_entry.has_focus() + ): return False # Spacebar, home/prev diff --git a/sublime/ui/albums.py b/sublime/ui/albums.py index 31bddc9..df06ffd 100644 --- a/sublime/ui/albums.py +++ b/sublime/ui/albums.py @@ -555,7 +555,9 @@ class AlbumsGrid(Gtk.Overlay): grid_detail_grid_box.add(self.grid_top) self.detail_box_revealer = Gtk.Revealer(valign=Gtk.Align.END) - self.detail_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + self.detail_box = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, name="artist-detail-box" + ) self.detail_box.pack_start(Gtk.Box(), True, True, 0) self.detail_box_inner = Gtk.Box() diff --git a/sublime/ui/app_styles.css b/sublime/ui/app_styles.css index 88aaa96..ab599f7 100644 --- a/sublime/ui/app_styles.css +++ b/sublime/ui/app_styles.css @@ -261,3 +261,12 @@ #artist-info-panel { margin-bottom: 10px; } + +@define-color detail_color rgba(0, 0, 0, 0.2); +#artist-detail-box { + padding-top: 10px; + padding-bottom: 10px; + box-shadow: inset 0 5px 5px @detail_color, + inset 0 -5px 5px @detail_color; + background-color: @detail_color; +} diff --git a/sublime/ui/artists.py b/sublime/ui/artists.py index a45cad9..c1d3319 100644 --- a/sublime/ui/artists.py +++ b/sublime/ui/artists.py @@ -232,6 +232,7 @@ class ArtistDetailPanel(Gtk.Box): name="playlist-play-shuffle-buttons", ) + # TODO: make these disabled if there are no songs that can be played. play_button = IconButton( "media-playback-start-symbolic", label="Play All", relief=True, ) @@ -341,7 +342,11 @@ class ArtistDetailPanel(Gtk.Box): self.artist_indicator.set_text("ARTIST") self.artist_stats.set_markup(self.format_stats(artist)) - self.artist_bio.set_markup(util.esc(artist.biography)) + if artist.biography: + self.artist_bio.set_markup(util.esc(artist.biography)) + self.artist_bio.show() + else: + self.artist_bio.hide() if len(artist.similar_artists or []) > 0: self.similar_artists_label.set_markup("Similar Artists: ") @@ -450,7 +455,7 @@ class ArtistDetailPanel(Gtk.Box): self.albums_list.spinner.hide() self.artist_artwork.set_loading(False) - def make_label(self, text: str = None, name: str = None, **params,) -> Gtk.Label: + def make_label(self, text: str = None, name: str = None, **params) -> Gtk.Label: return Gtk.Label( label=text, name=name, halign=Gtk.Align.START, xalign=0, **params, ) @@ -538,7 +543,11 @@ class AlbumsListWithSongs(Gtk.Overlay): album_with_songs.show_all() self.box.add(album_with_songs) - self.spinner.stop() + # Update everything (no force to ensure that if we are online, then everything + # is clickable) + for c in self.box.get_children(): + c.update(app_config=app_config) + self.spinner.hide() def on_song_selected(self, album_component: AlbumWithSongs): diff --git a/sublime/ui/browse.py b/sublime/ui/browse.py index 3cd921e..27b6f8e 100644 --- a/sublime/ui/browse.py +++ b/sublime/ui/browse.py @@ -207,27 +207,28 @@ class MusicDirectoryList(Gtk.Box): self.list.bind_model(self.drilldown_directories_store, self.create_row) scrollbox.add(self.list) - self.directory_song_store = Gtk.ListStore( - str, str, str, str, # cache status, title, duration, song ID - ) + # clickable, cache status, title, duration, song ID + self.directory_song_store = Gtk.ListStore(bool, str, str, str, str) self.directory_song_list = Gtk.TreeView( model=self.directory_song_store, name="directory-songs-list", headers_visible=False, ) - self.directory_song_list.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE) + selection = self.directory_song_list.get_selection() + selection.set_mode(Gtk.SelectionMode.MULTIPLE) + selection.set_select_function(lambda _, model, path, current: model[path[0]][0]) # Song status column. renderer = Gtk.CellRendererPixbuf() renderer.set_fixed_size(30, 35) - column = Gtk.TreeViewColumn("", renderer, icon_name=0) + column = Gtk.TreeViewColumn("", renderer, icon_name=1) column.set_resizable(True) self.directory_song_list.append_column(column) - self.directory_song_list.append_column(SongListColumn("TITLE", 1, bold=True)) + self.directory_song_list.append_column(SongListColumn("TITLE", 2, bold=True)) self.directory_song_list.append_column( - SongListColumn("DURATION", 2, align=1, width=40) + SongListColumn("DURATION", 3, align=1, width=40) ) self.directory_song_list.connect("row-activated", self.on_song_activated) @@ -253,6 +254,10 @@ class MusicDirectoryList(Gtk.Box): ) if app_config: + # Deselect everything if switching online to offline. + if self.offline_mode != app_config.offline_mode: + self.directory_song_list.get_selection().unselect_all() + self.offline_mode = app_config.offline_mode self.refresh_button.set_sensitive(not self.offline_mode) @@ -316,6 +321,11 @@ class MusicDirectoryList(Gtk.Box): new_songs_store = [ [ + ( + not self.offline_mode + or status_icon + in ("folder-download-symbolic", "view-pin-symbolic") + ), status_icon, util.esc(song.title), util.format_song_duration(song.duration), @@ -327,7 +337,15 @@ class MusicDirectoryList(Gtk.Box): ] else: new_songs_store = [ - [status_icon] + song_model[1:] + [ + ( + not self.offline_mode + or status_icon + in ("folder-download-symbolic", "view-pin-symbolic") + ), + status_icon, + *song_model[2:], + ] for status_icon, song_model in zip( util.get_cached_status_icons(song_ids), self.directory_song_store ) @@ -384,6 +402,8 @@ class MusicDirectoryList(Gtk.Box): # Event Handlers # ================================================================================== def on_song_activated(self, treeview: Any, idx: Gtk.TreePath, column: Any): + if not self.directory_song_store[idx[0]][0]: + return # The song ID is in the last column of the model. self.emit( "song-clicked", diff --git a/sublime/ui/common/album_with_songs.py b/sublime/ui/common/album_with_songs.py index 13acf62..4158e6c 100644 --- a/sublime/ui/common/album_with_songs.py +++ b/sublime/ui/common/album_with_songs.py @@ -1,5 +1,5 @@ from random import randint -from typing import Any, List +from typing import Any, cast, List from gi.repository import Gdk, GLib, GObject, Gtk, Pango @@ -125,8 +125,8 @@ class AlbumWithSongs(Gtk.Box): self.loading_indicator_container = Gtk.Box() album_details.add(self.loading_indicator_container) - # cache status, title, duration, song ID - self.album_song_store = Gtk.ListStore(str, str, str, str) + # clickable, cache status, title, duration, song ID + self.album_song_store = Gtk.ListStore(bool, str, str, str, str) self.album_songs = Gtk.TreeView( model=self.album_song_store, @@ -137,17 +137,19 @@ class AlbumWithSongs(Gtk.Box): margin_right=10, margin_bottom=10, ) - self.album_songs.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE) + selection = self.album_songs.get_selection() + selection.set_mode(Gtk.SelectionMode.MULTIPLE) + selection.set_select_function(lambda _, model, path, current: model[path[0]][0]) # Song status column. renderer = Gtk.CellRendererPixbuf() renderer.set_fixed_size(30, 35) - column = Gtk.TreeViewColumn("", renderer, icon_name=0) + column = Gtk.TreeViewColumn("", renderer, icon_name=1) column.set_resizable(True) self.album_songs.append_column(column) - self.album_songs.append_column(SongListColumn("TITLE", 1, bold=True)) - self.album_songs.append_column(SongListColumn("DURATION", 2, align=1, width=40)) + self.album_songs.append_column(SongListColumn("TITLE", 2, bold=True)) + self.album_songs.append_column(SongListColumn("DURATION", 3, align=1, width=40)) self.album_songs.connect("row-activated", self.on_song_activated) self.album_songs.connect("button-press-event", self.on_song_button_press) @@ -167,6 +169,8 @@ class AlbumWithSongs(Gtk.Box): self.emit("song-selected") def on_song_activated(self, treeview: Any, idx: Gtk.TreePath, column: Any): + if not self.album_song_store[idx[0]][0]: + return # The song ID is in the last column of the model. self.emit( "song-clicked", @@ -243,6 +247,9 @@ class AlbumWithSongs(Gtk.Box): def update(self, app_config: AppConfiguration = None, force: bool = False): if app_config: + # Deselect everything if switching online to offline. + if self.offline_mode != app_config.offline_mode: + self.album_songs.get_selection().unselect_all() self.offline_mode = app_config.offline_mode self.update_album_songs(self.album.id, app_config=app_config, force=force) @@ -273,30 +280,42 @@ class AlbumWithSongs(Gtk.Box): order_token: int = None, ): song_ids = [s.id for s in album.songs or []] - new_store = [ - [ - cached_status, - util.esc(song.title), - util.format_song_duration(song.duration), - song.id, - ] - for cached_status, song in zip( - util.get_cached_status_icons(song_ids), album.songs or [] + new_store = [] + any_song_playable = False + for cached_status, song in zip( + util.get_cached_status_icons(song_ids), album.songs or [] + ): + playable = not self.offline_mode or cached_status in ( + "folder-download-symbolic", + "view-pin-symbolic", ) - ] + new_store.append( + [ + playable, + cached_status, + util.esc(song.title), + util.format_song_duration(song.duration), + song.id, + ] + ) + any_song_playable |= playable - song_ids = [song[-1] for song in new_store] + song_ids = [cast(str, song[-1]) for song in new_store] - self.play_btn.set_sensitive(True) - self.shuffle_btn.set_sensitive(True) + self.play_btn.set_sensitive(any_song_playable) + self.shuffle_btn.set_sensitive(any_song_playable) self.download_all_btn.set_sensitive( not self.offline_mode and AdapterManager.can_batch_download_songs() ) - self.play_next_btn.set_action_target_value(GLib.Variant("as", song_ids)) - self.add_to_queue_btn.set_action_target_value(GLib.Variant("as", song_ids)) - self.play_next_btn.set_action_name("app.add-to-queue") - self.add_to_queue_btn.set_action_name("app.play-next") + if any_song_playable: + self.play_next_btn.set_action_target_value(GLib.Variant("as", song_ids)) + self.add_to_queue_btn.set_action_target_value(GLib.Variant("as", song_ids)) + self.play_next_btn.set_action_name("app.add-to-queue") + self.add_to_queue_btn.set_action_name("app.play-next") + else: + self.play_next_btn.set_action_name("") + self.add_to_queue_btn.set_action_name("") util.diff_song_store(self.album_song_store, new_store) diff --git a/sublime/ui/common/song_list_column.py b/sublime/ui/common/song_list_column.py index f3a6d0e..7a63fc3 100644 --- a/sublime/ui/common/song_list_column.py +++ b/sublime/ui/common/song_list_column.py @@ -17,6 +17,6 @@ class SongListColumn(Gtk.TreeViewColumn): ) renderer.set_fixed_size(width or -1, 35) - super().__init__(header, renderer, text=text_idx) + super().__init__(header, renderer, text=text_idx, sensitive=0) self.set_resizable(True) self.set_expand(not width) diff --git a/sublime/ui/images/play-queue-play.svg b/sublime/ui/images/play-queue-play.svg index b17b764..2e747cb 100644 --- a/sublime/ui/images/play-queue-play.svg +++ b/sublime/ui/images/play-queue-play.svg @@ -1,97 +1,4 @@ - - - - - - - - - - image/svg+xml - - - - - - - - - + + + diff --git a/sublime/ui/main.py b/sublime/ui/main.py index aa9041b..06e3259 100644 --- a/sublime/ui/main.py +++ b/sublime/ui/main.py @@ -406,6 +406,7 @@ class MainWindow(Gtk.ApplicationWindow): lambda _: print("switch"), menu_name="switch-provider", ) + # TODO music_provider_button.set_action_name("app.configure-servers") vbox.add(music_provider_button) diff --git a/sublime/ui/playlists.py b/sublime/ui/playlists.py index 6744c20..4c2f07c 100644 --- a/sublime/ui/playlists.py +++ b/sublime/ui/playlists.py @@ -290,17 +290,17 @@ class PlaylistDetailPanel(Gtk.Overlay): name="playlist-play-shuffle-buttons", ) - play_button = IconButton( + self.play_all_button = IconButton( "media-playback-start-symbolic", label="Play All", relief=True, ) - play_button.connect("clicked", self.on_play_all_clicked) - self.play_shuffle_buttons.pack_start(play_button, False, False, 0) + self.play_all_button.connect("clicked", self.on_play_all_clicked) + self.play_shuffle_buttons.pack_start(self.play_all_button, False, False, 0) - shuffle_button = IconButton( + self.shuffle_all_button = IconButton( "media-playlist-shuffle-symbolic", label="Shuffle All", relief=True, ) - shuffle_button.connect("clicked", self.on_shuffle_all_button) - self.play_shuffle_buttons.pack_start(shuffle_button, False, False, 5) + self.shuffle_all_button.connect("clicked", self.on_shuffle_all_button) + self.play_shuffle_buttons.pack_start(self.shuffle_all_button, False, False, 5) playlist_details_box.add(self.play_shuffle_buttons) @@ -352,6 +352,7 @@ class PlaylistDetailPanel(Gtk.Overlay): playlist_view_scroll_window = Gtk.ScrolledWindow() self.playlist_song_store = Gtk.ListStore( + bool, # clickable str, # cache status str, # title str, # album @@ -391,20 +392,22 @@ class PlaylistDetailPanel(Gtk.Overlay): enable_search=True, ) self.playlist_songs.set_search_equal_func(playlist_song_list_search_fn) - self.playlist_songs.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE) + selection = self.playlist_songs.get_selection() + selection.set_mode(Gtk.SelectionMode.MULTIPLE) + selection.set_select_function(lambda _, model, path, current: model[path[0]][0]) # Song status column. renderer = Gtk.CellRendererPixbuf() renderer.set_fixed_size(30, 35) - column = Gtk.TreeViewColumn("", renderer, icon_name=0) + column = Gtk.TreeViewColumn("", renderer, icon_name=1) column.set_resizable(True) self.playlist_songs.append_column(column) - self.playlist_songs.append_column(SongListColumn("TITLE", 1, bold=True)) - self.playlist_songs.append_column(SongListColumn("ALBUM", 2)) - self.playlist_songs.append_column(SongListColumn("ARTIST", 3)) + self.playlist_songs.append_column(SongListColumn("TITLE", 2, bold=True)) + self.playlist_songs.append_column(SongListColumn("ALBUM", 3)) + self.playlist_songs.append_column(SongListColumn("ARTIST", 4)) self.playlist_songs.append_column( - SongListColumn("DURATION", 4, align=1, width=40) + SongListColumn("DURATION", 5, align=1, width=40) ) self.playlist_songs.connect("row-activated", self.on_song_activated) @@ -434,6 +437,10 @@ class PlaylistDetailPanel(Gtk.Overlay): update_playlist_view_order_token = 0 def update(self, app_config: AppConfiguration, force: bool = False): + # Deselect everything if switching online to offline. + if self.offline_mode != app_config.offline_mode: + self.playlist_songs.get_selection().unselect_all() + self.offline_mode = app_config.offline_mode if app_config.state.selected_playlist_id is None: self.playlist_box.hide() @@ -532,33 +539,47 @@ class PlaylistDetailPanel(Gtk.Overlay): song_ids.append(c.id) songs.append(c) + new_songs_store = [] + can_play_any_song = False + cached_status_icons = ("folder-download-symbolic", "view-pin-symbolic") + if force: self._current_song_ids = song_ids - new_songs_store = [ - [ - status_icon, - song.title, - album.name if (album := song.album) else None, - artist.name if (artist := song.artist) else None, - util.format_song_duration(song.duration), - song.id, - ] - for status_icon, song in zip( - util.get_cached_status_icons(song_ids), - [cast(API.Song, s) for s in songs], + # Regenerate the store from the actual song data (this is more expensive + # because when coming from the cache, we are doing 2N fk requests to + # albums). + for status_icon, song in zip( + util.get_cached_status_icons(song_ids), + [cast(API.Song, s) for s in songs], + ): + playable = not self.offline_mode or status_icon in cached_status_icons + can_play_any_song |= playable + new_songs_store.append( + [ + playable, + status_icon, + song.title, + album.name if (album := song.album) else None, + artist.name if (artist := song.artist) else None, + util.format_song_duration(song.duration), + song.id, + ] ) - ] else: - new_songs_store = [ - [status_icon] + song_model[1:] - for status_icon, song_model in zip( - util.get_cached_status_icons(song_ids), self.playlist_song_store - ) - ] + # Just update the clickable state and download state. + for status_icon, song_model in zip( + util.get_cached_status_icons(song_ids), self.playlist_song_store + ): + playable = not self.offline_mode or status_icon in cached_status_icons + can_play_any_song |= playable + new_songs_store.append([playable, status_icon, *song_model[2:]]) util.diff_song_store(self.playlist_song_store, new_songs_store) + self.play_all_button.set_sensitive(can_play_any_song) + self.shuffle_all_button.set_sensitive(can_play_any_song) + self.editing_playlist_song_list = False self.playlist_view_loading_box.hide() @@ -657,7 +678,7 @@ class PlaylistDetailPanel(Gtk.Overlay): def download_state_change(song_id: str): GLib.idle_add( lambda: self.update_playlist_view( - self.playlist_id, order_token=self.update_playlist_view_order_token, + self.playlist_id, order_token=self.update_playlist_view_order_token ) ) @@ -757,7 +778,12 @@ class PlaylistDetailPanel(Gtk.Overlay): self.offline_mode, on_download_state_change=on_download_state_change, extra_menu_items=[ - (Gtk.ModelButton(text=remove_text), on_remove_songs_click), + ( + Gtk.ModelButton( + text=remove_text, sensitive=not self.offline_mode + ), + on_remove_songs_click, + ) ], on_playlist_state_change=lambda: self.emit("refresh-window", {}, True), ) diff --git a/sublime/ui/util.py b/sublime/ui/util.py index e4ef3b4..96ea67a 100644 --- a/sublime/ui/util.py +++ b/sublime/ui/util.py @@ -218,14 +218,18 @@ def show_song_popover( # Add all of the menu items to the popover. song_count = len(song_ids) - go_to_album_button = Gtk.ModelButton( - text="Go to album", action_name="app.go-to-album" - ) - go_to_artist_button = Gtk.ModelButton( - text="Go to artist", action_name="app.go-to-artist" - ) + play_next_button = Gtk.ModelButton(text="Play next", sensitive=False) + add_to_queue_button = Gtk.ModelButton(text="Add to queue", sensitive=False) + if not offline_mode: + play_next_button.set_action_name("app.play-next") + play_next_button.set_action_target_value(GLib.Variant("as", song_ids)) + add_to_queue_button.set_action_name("app.add-to-queue") + add_to_queue_button.set_action_target_value(GLib.Variant("as", song_ids)) + + go_to_album_button = Gtk.ModelButton(text="Go to album", sensitive=False) + go_to_artist_button = Gtk.ModelButton(text="Go to artist", sensitive=False) browse_to_song = Gtk.ModelButton( - text=f"Browse to {pluralize('song', song_count)}", action_name="app.browse-to", + text=f"Browse to {pluralize('song', song_count)}", sensitive=False ) download_song_button = Gtk.ModelButton( text=f"Download {pluralize('song', song_count)}", sensitive=False @@ -246,6 +250,10 @@ def show_song_popover( for status in song_cache_statuses ): remove_download_button.set_sensitive(True) + play_next_button.set_action_target_value(GLib.Variant("as", song_ids)) + play_next_button.set_action_name("app.play-next") + add_to_queue_button.set_action_target_value(GLib.Variant("as", song_ids)) + add_to_queue_button.set_action_name("app.add-to-queue") albums, artists, parents = set(), set(), set() for song in songs: @@ -258,14 +266,18 @@ def show_song_popover( artists.add(id_) if len(albums) == 1 and list(albums)[0] is not None: - album_value = GLib.Variant("s", list(albums)[0]) - go_to_album_button.set_action_target_value(album_value) + go_to_album_button.set_action_target_value( + GLib.Variant("s", list(albums)[0]) + ) + go_to_album_button.set_action_name("app.go-to-album") if len(artists) == 1 and list(artists)[0] is not None: - artist_value = GLib.Variant("s", list(artists)[0]) - go_to_artist_button.set_action_target_value(artist_value) + go_to_artist_button.set_action_target_value( + GLib.Variant("s", list(artists)[0]) + ) + go_to_artist_button.set_action_name("app.go-to-artist") if len(parents) == 1 and list(parents)[0] is not None: - parent_value = GLib.Variant("s", list(parents)[0]) - browse_to_song.set_action_target_value(parent_value) + browse_to_song.set_action_target_value(GLib.Variant("s", list(parents)[0])) + browse_to_song.set_action_name("app.browse-to") def batch_get_song_details() -> List[Song]: return [ @@ -278,16 +290,8 @@ def show_song_popover( ) menu_items = [ - Gtk.ModelButton( - text="Play next", - action_name="app.play-next", - action_target=GLib.Variant("as", song_ids), - ), - Gtk.ModelButton( - text="Add to queue", - action_name="app.add-to-queue", - action_target=GLib.Variant("as", song_ids), - ), + play_next_button, + add_to_queue_button, Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), go_to_album_button, go_to_artist_button, @@ -300,6 +304,7 @@ def show_song_popover( text=f"Add {pluralize('song', song_count)} to playlist", menu_name="add-to-playlist", name="menu-item-add-to-playlist", + sensitive=not offline_mode, ), *(extra_menu_items or []), ] @@ -319,27 +324,30 @@ def show_song_popover( # Create the "Add song(s) to playlist" sub-menu. playlists_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - # Back button - playlists_vbox.add(Gtk.ModelButton(inverted=True, centered=True, menu_name="main")) + if not offline_mode: + # Back button + playlists_vbox.add( + Gtk.ModelButton(inverted=True, centered=True, menu_name="main") + ) - # Loading indicator - loading_indicator = Gtk.Spinner(name="menu-item-spinner") - loading_indicator.start() - playlists_vbox.add(loading_indicator) + # Loading indicator + loading_indicator = Gtk.Spinner(name="menu-item-spinner") + loading_indicator.start() + playlists_vbox.add(loading_indicator) - # Create a future to make the actual playlist buttons - def on_get_playlists_done(f: Result[List[Playlist]]): - playlists_vbox.remove(loading_indicator) + # Create a future to make the actual playlist buttons + def on_get_playlists_done(f: Result[List[Playlist]]): + playlists_vbox.remove(loading_indicator) - for playlist in f.result(): - button = Gtk.ModelButton(text=playlist.name) - button.get_style_context().add_class("menu-button") - button.connect("clicked", on_add_to_playlist_click, playlist) - button.show() - playlists_vbox.pack_start(button, False, True, 0) + for playlist in f.result(): + button = Gtk.ModelButton(text=playlist.name) + button.get_style_context().add_class("menu-button") + button.connect("clicked", on_add_to_playlist_click, playlist) + button.show() + playlists_vbox.pack_start(button, False, True, 0) - playlists_result = AdapterManager.get_playlists() - playlists_result.add_done_callback(on_get_playlists_done) + playlists_result = AdapterManager.get_playlists() + playlists_result.add_done_callback(on_get_playlists_done) popover.add(playlists_vbox) popover.child_set_property(playlists_vbox, "submenu", "add-to-playlist") From 209491204f12c7927f8950348e0eab6e27f6eda4 Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Sun, 24 May 2020 21:28:27 -0600 Subject: [PATCH 17/41] Play queue enabled/disabled depending on cache state --- pyproject.toml | 6 +++ sublime/ui/app_styles.css | 15 ++++-- sublime/ui/artists.py | 4 +- sublime/ui/player_controls.py | 47 +++++++++++++------ sublime/ui/playlists.py | 2 + tests/adapter_tests/subsonic_adapter_tests.py | 11 +++-- tests/config_test.py | 5 +- 7 files changed, 63 insertions(+), 27 deletions(-) create mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f4b3e4c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,6 @@ +[tool.black] +exclude = ''' +( + /flatpak/ +) +''' diff --git a/sublime/ui/app_styles.css b/sublime/ui/app_styles.css index ab599f7..7c9918f 100644 --- a/sublime/ui/app_styles.css +++ b/sublime/ui/app_styles.css @@ -181,6 +181,10 @@ min-width: 50px; } +#play-queue-image-disabled { + opacity: 0.5; +} + /* ********** General ********** */ .menu-button { padding: 5px; @@ -258,15 +262,18 @@ margin: 15px; } +@define-color box_shadow_color rgba(0, 0, 0, 0.2); + #artist-info-panel { + box-shadow: 0 5px 5px @box_shadow_color; margin-bottom: 10px; + padding-bottom: 10px; } -@define-color detail_color rgba(0, 0, 0, 0.2); #artist-detail-box { padding-top: 10px; padding-bottom: 10px; - box-shadow: inset 0 5px 5px @detail_color, - inset 0 -5px 5px @detail_color; - background-color: @detail_color; + box-shadow: inset 0 5px 5px @box_shadow_color, + inset 0 -5px 5px @box_shadow_color; + background-color: @box_shadow_color; } diff --git a/sublime/ui/artists.py b/sublime/ui/artists.py index c1d3319..47280ae 100644 --- a/sublime/ui/artists.py +++ b/sublime/ui/artists.py @@ -234,13 +234,13 @@ class ArtistDetailPanel(Gtk.Box): # TODO: make these disabled if there are no songs that can be played. play_button = IconButton( - "media-playback-start-symbolic", label="Play All", relief=True, + "media-playback-start-symbolic", label="Play All", relief=True ) play_button.connect("clicked", self.on_play_all_clicked) self.play_shuffle_buttons.pack_start(play_button, False, False, 0) shuffle_button = IconButton( - "media-playlist-shuffle-symbolic", label="Shuffle All", relief=True, + "media-playlist-shuffle-symbolic", label="Shuffle All", relief=True ) shuffle_button.connect("clicked", self.on_shuffle_all_button) self.play_shuffle_buttons.pack_start(shuffle_button, False, False, 5) diff --git a/sublime/ui/player_controls.py b/sublime/ui/player_controls.py index 2c83b37..403cf46 100644 --- a/sublime/ui/player_controls.py +++ b/sublime/ui/player_controls.py @@ -7,7 +7,7 @@ from typing import Any, Callable, List, Optional, Tuple from gi.repository import Gdk, GdkPixbuf, GLib, GObject, Gtk, Pango from pychromecast import Chromecast -from sublime.adapters import AdapterManager, Result +from sublime.adapters import AdapterManager, Result, SongCacheStatus from sublime.adapters.api_objects import Song from sublime.config import AppConfiguration from sublime.players import ChromecastPlayer @@ -177,6 +177,7 @@ class PlayerControls(Gtk.ActionBar): self.update_device_list() # Short circuit if no changes to the play queue + force |= self.offline_mode != app_config.offline_mode self.offline_mode = app_config.offline_mode self.load_play_queue_button.set_sensitive(not self.offline_mode) @@ -224,7 +225,7 @@ class PlayerControls(Gtk.ActionBar): if order_token != self.play_queue_update_order_token: return - self.play_queue_store[idx][0] = cover_art_filename + self.play_queue_store[idx][1] = cover_art_filename def get_cover_art_filename_or_create_future( cover_art_id: Optional[str], idx: int, order_token: int @@ -247,21 +248,26 @@ class PlayerControls(Gtk.ActionBar): if order_token != self.play_queue_update_order_token: return - self.play_queue_store[idx][1] = calculate_label(song_details) + self.play_queue_store[idx][2] = calculate_label(song_details) # Cover Art filename = get_cover_art_filename_or_create_future( song_details.cover_art, idx, order_token ) if filename: - self.play_queue_store[idx][0] = filename + self.play_queue_store[idx][1] = filename current_play_queue = [x[-1] for x in self.play_queue_store] if app_config.state.play_queue != current_play_queue: self.play_queue_update_order_token += 1 song_details_results = [] - for i, song_id in enumerate(app_config.state.play_queue): + for i, (song_id, cached_status) in enumerate( + zip( + app_config.state.play_queue, + AdapterManager.get_cached_statuses(app_config.state.play_queue), + ) + ): song_details_result = AdapterManager.get_song_details(song_id) cover_art_filename = "" @@ -282,6 +288,11 @@ class PlayerControls(Gtk.ActionBar): new_store.append( [ + ( + not self.offline_mode + or cached_status + in (SongCacheStatus.CACHED, SongCacheStatus.PERMANENTLY_CACHED) + ), cover_art_filename, label, i == app_config.state.current_song_index, @@ -361,6 +372,8 @@ class PlayerControls(Gtk.ActionBar): self.play_queue_popover.show_all() def on_song_activated(self, t: Any, idx: Gtk.TreePath, c: Any): + if not self.play_queue_store[idx[0]][0]: + return # The song ID is in the last column of the model. self.emit( "song-clicked", @@ -481,7 +494,7 @@ class PlayerControls(Gtk.ActionBar): # reordering_play_queue_song_list flag. if self.reordering_play_queue_song_list: currently_playing_index = [ - i for i, s in enumerate(self.play_queue_store) if s[2] + i for i, s in enumerate(self.play_queue_store) if s[3] # playing ][0] self.emit( "refresh-window", @@ -706,6 +719,7 @@ class PlayerControls(Gtk.ActionBar): ) self.play_queue_store = Gtk.ListStore( + bool, # playable str, # image filename str, # title, album, artist bool, # playing @@ -714,30 +728,35 @@ class PlayerControls(Gtk.ActionBar): self.play_queue_list = Gtk.TreeView( model=self.play_queue_store, reorderable=True, headers_visible=False, ) - self.play_queue_list.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE) + selection = self.play_queue_list.get_selection() + selection.set_mode(Gtk.SelectionMode.MULTIPLE) + selection.set_select_function(lambda _, model, path, current: model[path[0]][0]) - # Album Art column. + # Album Art column. This function defines what image to use for the play queue + # song icon. def filename_to_pixbuf( column: Any, cell: Gtk.CellRendererPixbuf, model: Gtk.ListStore, - iter: Gtk.TreeIter, + tree_iter: Gtk.TreeIter, flags: Any, ): - filename = model.get_value(iter, 0) + cell.set_property("sensitive", model.get_value(tree_iter, 0)) + filename = model.get_value(tree_iter, 1) if not filename: cell.set_property("icon_name", "") return + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(filename, 50, 50, True) # If this is the playing song, then overlay the play icon. - if model.get_value(iter, 2): + if model.get_value(tree_iter, 3): play_overlay_pixbuf = GdkPixbuf.Pixbuf.new_from_file( str(Path(__file__).parent.joinpath("images/play-queue-play.png")) ) play_overlay_pixbuf.composite( - pixbuf, 0, 0, 50, 50, 0, 0, 1, 1, GdkPixbuf.InterpType.NEAREST, 255 + pixbuf, 0, 0, 50, 50, 0, 0, 1, 1, GdkPixbuf.InterpType.NEAREST, 200 ) cell.set_property("pixbuf", pixbuf) @@ -749,8 +768,8 @@ class PlayerControls(Gtk.ActionBar): column.set_resizable(True) self.play_queue_list.append_column(column) - renderer = Gtk.CellRendererText(markup=True, ellipsize=Pango.EllipsizeMode.END,) - column = Gtk.TreeViewColumn("", renderer, markup=1) + renderer = Gtk.CellRendererText(markup=True, ellipsize=Pango.EllipsizeMode.END) + column = Gtk.TreeViewColumn("", renderer, markup=2, sensitive=0) self.play_queue_list.append_column(column) self.play_queue_list.connect("row-activated", self.on_song_activated) diff --git a/sublime/ui/playlists.py b/sublime/ui/playlists.py index 4c2f07c..23714d9 100644 --- a/sublime/ui/playlists.py +++ b/sublime/ui/playlists.py @@ -713,6 +713,8 @@ class PlaylistDetailPanel(Gtk.Overlay): ) def on_song_activated(self, _, idx: Gtk.TreePath, col: Any): + if not self.playlist_song_store[idx[0]][0]: + return # The song ID is in the last column of the model. self.emit( "song-clicked", diff --git a/tests/adapter_tests/subsonic_adapter_tests.py b/tests/adapter_tests/subsonic_adapter_tests.py index 236a91c..be33ca5 100644 --- a/tests/adapter_tests/subsonic_adapter_tests.py +++ b/tests/adapter_tests/subsonic_adapter_tests.py @@ -6,6 +6,7 @@ from pathlib import Path from typing import Any, Generator, List, Tuple import pytest +from dateutil.tz import tzutc from sublime.adapters.subsonic import ( api_objects as SubsonicAPI, @@ -111,8 +112,8 @@ def test_get_playlists(adapter: SubsonicAdapter): name="Test", song_count=132, duration=timedelta(seconds=33072), - created=datetime(2020, 3, 27, 5, 38, 45, 0, tzinfo=timezone.utc), - changed=datetime(2020, 4, 9, 16, 3, 26, 0, tzinfo=timezone.utc), + created=datetime(2020, 3, 27, 5, 38, 45, 0, tzinfo=tzutc()), + changed=datetime(2020, 4, 9, 16, 3, 26, 0, tzinfo=tzutc()), comment="Foo", owner="foo", public=True, @@ -123,8 +124,8 @@ def test_get_playlists(adapter: SubsonicAdapter): name="Bar", song_count=23, duration=timedelta(seconds=847), - created=datetime(2020, 3, 27, 5, 39, 4, 0, tzinfo=timezone.utc), - changed=datetime(2020, 3, 27, 5, 45, 23, 0, tzinfo=timezone.utc), + created=datetime(2020, 3, 27, 5, 39, 4, 0, tzinfo=tzutc()), + changed=datetime(2020, 3, 27, 5, 45, 23, 0, tzinfo=tzutc()), comment="", owner="foo", public=False, @@ -136,7 +137,7 @@ def test_get_playlists(adapter: SubsonicAdapter): logging.info(filename) logging.debug(data) adapter._set_mock_data(data) - assert adapter.get_playlists() == expected + assert adapter.get_playlists() == sorted(expected, key=lambda e: e.name) # When playlists is null, expect an empty list. adapter._set_mock_data(mock_json()) diff --git a/tests/config_test.py b/tests/config_test.py index 4f76e1c..cc06070 100644 --- a/tests/config_test.py +++ b/tests/config_test.py @@ -46,14 +46,15 @@ def test_yaml_load_unload(): def test_config_migrate(): - config = AppConfiguration() + config = AppConfiguration(always_stream=True) server = ServerConfiguration( name="Test", server_address="https://test.host", username="test" ) config.servers.append(server) config.migrate() - assert config.version == 3 + assert config.version == 4 + assert config.allow_song_downloads is False for server in config.servers: server.version == 0 From b79ce9e205eac9463884a54625201433ab540d54 Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Sun, 24 May 2020 21:34:54 -0600 Subject: [PATCH 18/41] Fix bug with add to play queue/play next --- sublime/ui/common/album_with_songs.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sublime/ui/common/album_with_songs.py b/sublime/ui/common/album_with_songs.py index 4158e6c..5965f98 100644 --- a/sublime/ui/common/album_with_songs.py +++ b/sublime/ui/common/album_with_songs.py @@ -311,8 +311,8 @@ class AlbumWithSongs(Gtk.Box): if any_song_playable: self.play_next_btn.set_action_target_value(GLib.Variant("as", song_ids)) self.add_to_queue_btn.set_action_target_value(GLib.Variant("as", song_ids)) - self.play_next_btn.set_action_name("app.add-to-queue") - self.add_to_queue_btn.set_action_name("app.play-next") + self.play_next_btn.set_action_name("app.play-next") + self.add_to_queue_btn.set_action_name("app.add-to-queue") else: self.play_next_btn.set_action_name("") self.add_to_queue_btn.set_action_name("") From eee58f97e10ed30e461b77f1e80bbc82836953af Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Mon, 25 May 2020 18:50:38 -0600 Subject: [PATCH 19/41] Adding more offline mode load errors --- sublime/adapters/adapter_base.py | 4 +- sublime/adapters/images/default-album-art.png | Bin 3845 -> 6402 bytes sublime/adapters/images/default-album-art.svg | 116 +++++++++--------- sublime/adapters/manager.py | 36 ++++-- sublime/adapters/subsonic/adapter.py | 11 +- sublime/app.py | 9 ++ sublime/ui/albums.py | 40 +++++- sublime/ui/app_styles.css | 10 ++ sublime/ui/artists.py | 22 +++- sublime/ui/browse.py | 76 ++++++++++-- sublime/ui/common/__init__.py | 2 + sublime/ui/common/album_with_songs.py | 76 ++++++++---- sublime/ui/common/load_error.py | 59 +++++++++ sublime/ui/main.py | 19 +-- sublime/ui/player_controls.py | 1 + sublime/ui/playlists.py | 26 +++- sublime/ui/util.py | 16 ++- 17 files changed, 396 insertions(+), 127 deletions(-) create mode 100644 sublime/ui/common/load_error.py diff --git a/sublime/adapters/adapter_base.py b/sublime/adapters/adapter_base.py index 5a54434..6dda37d 100644 --- a/sublime/adapters/adapter_base.py +++ b/sublime/adapters/adapter_base.py @@ -312,6 +312,8 @@ class Adapter(abc.ABC): # These properties determine if what things the adapter can be used to do # at the current moment. # ================================================================================== + # TODO: change these to just be "if it has the attribute, then you can try the + # action" and use "ping_status" to determine online/offline status. @property @abc.abstractmethod def can_service_requests(self) -> bool: @@ -331,7 +333,7 @@ class Adapter(abc.ABC): @property def can_get_playlists(self) -> bool: """ - Whether :class:`get_playlist` can be called on the adapter right now. + Whether the adapter supports :class:`get_playlist`. """ return False diff --git a/sublime/adapters/images/default-album-art.png b/sublime/adapters/images/default-album-art.png index a5f2e119fb3beb9d47499ee7f11661e48a6ce478..2db43428a9e0c41419c7890ccc1319a6517c94a7 100644 GIT binary patch literal 6402 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 ZmH(*^*Dhh1G6n_)22WQ%mvv4FO#sRp3=04N diff --git a/sublime/adapters/images/default-album-art.svg b/sublime/adapters/images/default-album-art.svg index 9fce3a0..b2fcb5f 100644 --- a/sublime/adapters/images/default-album-art.svg +++ b/sublime/adapters/images/default-album-art.svg @@ -7,14 +7,17 @@ xmlns="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" - fill="#000000" - viewBox="0 0 24 24" - width="384px" - height="384px" - version="1.1" + inkscape:export-ydpi="103.984" + inkscape:export-xdpi="103.984" + inkscape:export-filename="/home/sumner/projects/sublime-music/sublime/adapters/images/default-album-art.png" + inkscape:version="1.0 (4035a4fb49, 2020-05-01)" + sodipodi:docname="default-album-art.svg" id="svg6" - sodipodi:docname="icons8-music.svg" - inkscape:version="0.92.4 5da689c313, 2019-01-14"> + version="1.1" + height="384px" + width="384px" + viewBox="0 0 24 24" + fill="#000000"> @@ -29,71 +32,72 @@ + inkscape:window-y="64" + inkscape:window-x="0" + inkscape:cy="255.37351" + inkscape:cx="249.24086" + inkscape:zoom="1.7383042" + showgrid="false" + id="namedview8" + inkscape:window-height="1374" + inkscape:window-width="2556" + inkscape:pageshadow="2" + inkscape:pageopacity="0" + guidetolerance="10" + gridtolerance="10" + objecttolerance="10" + borderopacity="1" + bordercolor="#666666" + pagecolor="#ffffff"> + orientation="0,384" + position="0,0" /> + orientation="-384,0" + position="24,0" /> + orientation="0,-384" + position="24,24" /> + orientation="384,0" + position="0,24" /> + x="0" + height="24" + width="24" + id="rect22" /> + style="stroke:#b3b3b3" + id="g26"> + id="path2" /> + stroke="#000000" + stroke-miterlimit="10" + stroke-width="2" + d="M12 18L12 4 18 4 18 8 13 8" + id="path4" /> diff --git a/sublime/adapters/manager.py b/sublime/adapters/manager.py index 2c9b1dc..0ece575 100644 --- a/sublime/adapters/manager.py +++ b/sublime/adapters/manager.py @@ -318,6 +318,7 @@ class AdapterManager: function_name: str, *params: Any, before_download: Callable[[], None] = None, + partial_data: Any = None, **kwargs, ) -> Result: """ @@ -337,7 +338,10 @@ class AdapterManager: if before_download: before_download() fn = getattr(AdapterManager._instance.ground_truth_adapter, function_name) - return fn(*params, **kwargs) + try: + return fn(*params, **kwargs) + except Exception: + raise CacheMissError(partial_data=partial_data) return Result(future_fn) @@ -431,7 +435,7 @@ class AdapterManager: assert AdapterManager._instance assert AdapterManager._instance.caching_adapter AdapterManager._instance.caching_adapter.ingest_new_data( - cache_key, param, f.result(), + cache_key, param, f.result() ) return future_finished @@ -517,24 +521,33 @@ class AdapterManager: cache_key, param_str ) + # TODO # TODO (#122) If any of the following fails, do we want to return what the # caching adapter has? - # TODO (#188): don't short circuit if not allow_download because it could be the - # filesystem adapter. - if not allow_download or not AdapterManager._ground_truth_can_do(function_name): + if ( + not allow_download + and AdapterManager._instance.ground_truth_adapter.is_networked + ) or not AdapterManager._ground_truth_can_do(function_name): logging.info(f"END: NO DOWNLOAD: {function_name}") - if partial_data: - # TODO (#122) indicate that this is partial data. Probably just re-throw - # here? - logging.debug("partial_data exists, returning", partial_data) - return Result(cast(AdapterManager.R, partial_data)) - raise Exception(f"No adapters can service {function_name} at the moment.") + + def cache_miss_result(): + raise CacheMissError(partial_data=partial_data) + + return Result(cache_miss_result) + # TODO + # raise CacheMissError(partial_data=partial_data) + # # TODO (#122) indicate that this is partial data. Probably just re-throw + # # here? + # logging.debug("partial_data exists, returning", partial_data) + # return Result(cast(AdapterManager.R, partial_data)) + # raise Exception(f"No adapters can service {function_name} at the moment.") result: Result[AdapterManager.R] = AdapterManager._create_ground_truth_result( function_name, *((param,) if param is not None else ()), before_download=before_download, + partial_data=partial_data, **kwargs, ) @@ -545,7 +558,6 @@ class AdapterManager: ) if on_result_finished: - # TODO (#122): figure out a way to pass partial data here result.add_done_callback(on_result_finished) logging.info(f"END: {function_name}") diff --git a/sublime/adapters/subsonic/adapter.py b/sublime/adapters/subsonic/adapter.py index c8f1a3b..c8bbfec 100644 --- a/sublime/adapters/subsonic/adapter.py +++ b/sublime/adapters/subsonic/adapter.py @@ -50,6 +50,10 @@ if delay_str := os.environ.get("REQUEST_DELAY"): else: REQUEST_DELAY = (float(delay_str), float(delay_str)) +NETWORK_ALWAYS_ERROR: bool = False +if always_error := os.environ.get("NETWORK_ALWAYS_ERROR"): + NETWORK_ALWAYS_ERROR = True + class SubsonicAdapter(Adapter): """ @@ -248,12 +252,15 @@ class SubsonicAdapter(Adapter): sleep(delay) if timeout: if type(timeout) == tuple: - if cast(Tuple[float, float], timeout)[0] > delay: + if delay > cast(Tuple[float, float], timeout)[0]: raise TimeoutError("DUMMY TIMEOUT ERROR") else: - if cast(float, timeout) > delay: + if delay > cast(float, timeout): raise TimeoutError("DUMMY TIMEOUT ERROR") + if NETWORK_ALWAYS_ERROR: + raise Exception("NETWORK_ALWAYS_ERROR enabled") + # Deal with datetime parameters (convert to milliseconds since 1970) for k, v in params.items(): if isinstance(v, datetime): diff --git a/sublime/app.py b/sublime/app.py index d086e94..a75497e 100644 --- a/sublime/app.py +++ b/sublime/app.py @@ -86,6 +86,15 @@ class SublimeMusicApp(Gtk.Application): add_action("browse-to", self.browse_to, parameter_type="s") add_action("go-to-playlist", self.on_go_to_playlist, parameter_type="s") + add_action( + "go-online", + lambda *a: self.on_refresh_window( + None, {"__settings__": {"offline_mode": False}} + ), + ) + add_action( + "refresh-window", lambda *a: self.on_refresh_window(None, {}, True), + ) add_action("mute-toggle", self.on_mute_toggle) add_action( "update-play-queue-from-server", diff --git a/sublime/ui/albums.py b/sublime/ui/albums.py index df06ffd..a2f1842 100644 --- a/sublime/ui/albums.py +++ b/sublime/ui/albums.py @@ -10,11 +10,12 @@ from sublime.adapters import ( AdapterManager, AlbumSearchQuery, api_objects as API, + CacheMissError, Result, ) from sublime.config import AppConfiguration from sublime.ui import util -from sublime.ui.common import AlbumWithSongs, IconButton, SpinnerImage +from sublime.ui.common import AlbumWithSongs, IconButton, LoadError, SpinnerImage def _to_type(query_type: AlbumSearchQuery.Type) -> str: @@ -59,6 +60,7 @@ class AlbumsPanel(Gtk.Box): ), } + offline_mode = False populating_genre_combo = False grid_order_token: int = 0 album_sort_direction: str = "ascending" @@ -233,6 +235,7 @@ class AlbumsPanel(Gtk.Box): if app_config: self.current_query = app_config.state.current_album_search_query + self.offline_mode = app_config.offline_mode self.alphabetical_type_combo.set_active_id( { @@ -301,7 +304,9 @@ class AlbumsPanel(Gtk.Box): self.populate_genre_combo(app_config, force=force) # At this point, the current query should be totally updated. - self.grid_order_token = self.grid.update_params(self.current_query) + self.grid_order_token = self.grid.update_params( + self.current_query, self.offline_mode + ) self.grid.update(self.grid_order_token, app_config, force=force) def _get_opposite_sort_dir(self, sort_dir: str) -> str: @@ -400,7 +405,7 @@ class AlbumsPanel(Gtk.Box): if self.to_year_spin_button == entry: new_year_tuple = (self.current_query.year_range[0], year) else: - new_year_tuple = (year, self.current_query.year_range[0]) + new_year_tuple = (year, self.current_query.year_range[1]) self.emit_if_not_updating( "refresh-window", @@ -505,6 +510,7 @@ class AlbumsGrid(Gtk.Overlay): current_models: List[_AlbumModel] = [] latest_applied_order_ratchet: int = 0 order_ratchet: int = 0 + offline_mode: bool = False currently_selected_index: Optional[int] = None currently_selected_id: Optional[str] = None @@ -515,11 +521,14 @@ class AlbumsGrid(Gtk.Overlay): next_page_fn = None server_hash: Optional[str] = None - def update_params(self, query: AlbumSearchQuery) -> int: + def update_params(self, query: AlbumSearchQuery, offline_mode: bool) -> int: # If there's a diff, increase the ratchet. if self.current_query.strhash() != query.strhash(): self.order_ratchet += 1 self.current_query = query + if offline_mode != self.offline_mode: + self.order_ratchet += 1 + self.offline_mode = offline_mode return self.order_ratchet def __init__(self, *args, **kwargs): @@ -530,6 +539,9 @@ class AlbumsGrid(Gtk.Overlay): scrolled_window = Gtk.ScrolledWindow() grid_detail_grid_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.error_container = Gtk.Box() + grid_detail_grid_box.add(self.error_container) + def create_flowbox(**kwargs) -> Gtk.FlowBox: flowbox = Gtk.FlowBox( **kwargs, @@ -661,8 +673,12 @@ class AlbumsGrid(Gtk.Overlay): return self.latest_applied_order_ratchet = self.order_ratchet + is_partial = False try: - albums = f.result() + albums = list(f.result()) + except CacheMissError as e: + albums = cast(Optional[List[API.Album]], e.partial_data) or [] + is_partial = True except Exception as e: if self.error_dialog: self.spinner.hide() @@ -685,6 +701,20 @@ class AlbumsGrid(Gtk.Overlay): self.spinner.hide() return + for c in self.error_container.get_children(): + self.error_container.remove(c) + if is_partial and self.current_query.type != AlbumSearchQuery.Type.RANDOM: + load_error = LoadError( + "Album list", + "load albums", + has_data=albums is not None and len(albums) > 0, + offline_mode=self.offline_mode, + ) + self.error_container.pack_start(load_error, True, True, 0) + self.error_container.show_all() + else: + self.error_container.hide() + selected_index = None self.current_models = [] for i, album in enumerate(albums): diff --git a/sublime/ui/app_styles.css b/sublime/ui/app_styles.css index 7c9918f..18a4816 100644 --- a/sublime/ui/app_styles.css +++ b/sublime/ui/app_styles.css @@ -214,6 +214,16 @@ min-height: 30px; } +/* ********** Error Indicator ********** */ +#load-error-box { + margin: 15px; +} + +#load-error-image, +#load-error-label { + margin-bottom: 10px; +} + /* ********** Artists & Albums ********** */ #grid-artwork-spinner, #album-list-song-list-spinner { min-height: 35px; diff --git a/sublime/ui/artists.py b/sublime/ui/artists.py index 47280ae..46dfeeb 100644 --- a/sublime/ui/artists.py +++ b/sublime/ui/artists.py @@ -7,7 +7,7 @@ from gi.repository import Gio, GLib, GObject, Gtk, Pango from sublime.adapters import AdapterManager, api_objects as API from sublime.config import AppConfiguration from sublime.ui import util -from sublime.ui.common import AlbumWithSongs, IconButton, SpinnerImage +from sublime.ui.common import AlbumWithSongs, IconButton, LoadError, SpinnerImage class ArtistsPanel(Gtk.Paned): @@ -72,6 +72,9 @@ class ArtistList(Gtk.Box): self.add(list_actions) + self.error_container = Gtk.Box() + self.add(self.error_container) + self.loading_indicator = Gtk.ListBox() spinner_row = Gtk.ListBoxRow(activatable=False, selectable=False) spinner = Gtk.Spinner(name="artist-list-spinner", active=True) @@ -124,12 +127,27 @@ class ArtistList(Gtk.Box): self, artists: Sequence[API.Artist], app_config: AppConfiguration = None, + is_partial: bool = False, **kwargs, ): if app_config: self._app_config = app_config self.refresh_button.set_sensitive(not app_config.offline_mode) + for c in self.error_container.get_children(): + self.error_container.remove(c) + if is_partial: + load_error = LoadError( + "Artist list", + "load artists", + has_data=len(artists) > 0, + offline_mode=self._app_config.offline_mode, + ) + self.error_container.pack_start(load_error, True, True, 0) + self.error_container.show_all() + else: + self.error_container.hide() + new_store = [] selected_idx = None for i, artist in enumerate(artists): @@ -318,6 +336,7 @@ class ArtistDetailPanel(Gtk.Box): app_config: AppConfiguration, force: bool = False, order_token: int = None, + is_partial: bool = False, ): if order_token != self.update_order_token: return @@ -393,6 +412,7 @@ class ArtistDetailPanel(Gtk.Box): app_config: AppConfiguration, force: bool = False, order_token: int = None, + is_partial: bool = False, ): if order_token != self.update_order_token: return diff --git a/sublime/ui/browse.py b/sublime/ui/browse.py index 27b6f8e..8312498 100644 --- a/sublime/ui/browse.py +++ b/sublime/ui/browse.py @@ -3,10 +3,10 @@ from typing import Any, cast, List, Optional, Tuple from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango -from sublime.adapters import AdapterManager, api_objects as API, Result +from sublime.adapters import AdapterManager, api_objects as API, CacheMissError, Result from sublime.config import AppConfiguration from sublime.ui import util -from sublime.ui.common import IconButton, SongListColumn +from sublime.ui.common import IconButton, LoadError, SongListColumn class BrowsePanel(Gtk.Overlay): @@ -30,6 +30,10 @@ class BrowsePanel(Gtk.Overlay): def __init__(self): super().__init__() scrolled_window = Gtk.ScrolledWindow() + window_box = Gtk.Box() + + self.error_container = Gtk.Box() + window_box.pack_start(self.error_container, True, True, 0) self.root_directory_listing = ListAndDrilldown() self.root_directory_listing.connect( @@ -38,8 +42,9 @@ class BrowsePanel(Gtk.Overlay): self.root_directory_listing.connect( "refresh-window", lambda _, *args: self.emit("refresh-window", *args), ) - scrolled_window.add(self.root_directory_listing) + window_box.add(self.root_directory_listing) + scrolled_window.add(window_box) self.add(scrolled_window) self.spinner = Gtk.Spinner( @@ -60,7 +65,22 @@ class BrowsePanel(Gtk.Overlay): if self.update_order_token != update_order_token: return - self.root_directory_listing.update(id_stack, app_config, force) + if len(id_stack) == 0: + self.root_directory_listing.hide() + if len(self.error_container.get_children()) == 0: + load_error = LoadError( + "Directory list", + "browse to song", + has_data=False, + offline_mode=app_config.offline_mode, + ) + self.error_container.pack_start(load_error, True, True, 0) + self.error_container.show_all() + else: + for c in self.error_container.get_children(): + self.error_container.remove(c) + self.error_container.hide() + self.root_directory_listing.update(id_stack, app_config, force) self.spinner.hide() def calculate_path() -> Tuple[str, ...]: @@ -68,13 +88,19 @@ class BrowsePanel(Gtk.Overlay): return ("root",) id_stack = [] - while current_dir_id and ( - directory := AdapterManager.get_directory( - current_dir_id, before_download=self.spinner.show, - ).result() - ): - id_stack.append(directory.id) - current_dir_id = directory.parent_id + while current_dir_id: + try: + directory = AdapterManager.get_directory( + current_dir_id, before_download=self.spinner.show, + ).result() + except CacheMissError as e: + directory = cast(API.Directory, e.partial_data) + + if not directory: + break + else: + id_stack.append(directory.id) + current_dir_id = directory.parent_id return tuple(id_stack) @@ -123,6 +149,7 @@ class ListAndDrilldown(Gtk.Paned): ): *child_id_stack, dir_id = id_stack selected_id = child_id_stack[-1] if len(child_id_stack) > 0 else None + self.show() self.list.update( directory_id=dir_id, @@ -199,6 +226,9 @@ class MusicDirectoryList(Gtk.Box): self.loading_indicator.add(spinner_row) self.pack_start(self.loading_indicator, False, False, 0) + self.error_container = Gtk.Box() + self.add(self.error_container) + self.scroll_window = Gtk.ScrolledWindow(min_content_width=250) scrollbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) @@ -257,6 +287,8 @@ class MusicDirectoryList(Gtk.Box): # Deselect everything if switching online to offline. if self.offline_mode != app_config.offline_mode: self.directory_song_list.get_selection().unselect_all() + for c in self.error_container.get_children(): + self.error_container.remove(c) self.offline_mode = app_config.offline_mode @@ -275,10 +307,26 @@ class MusicDirectoryList(Gtk.Box): app_config: AppConfiguration = None, force: bool = False, order_token: int = None, + is_partial: bool = False, ): if order_token != self.update_order_token: return + dir_children = directory.children or [] + for c in self.error_container.get_children(): + self.error_container.remove(c) + if is_partial: + load_error = LoadError( + "Directory listing", + "load directory", + has_data=len(dir_children) > 0, + offline_mode=self.offline_mode, + ) + self.error_container.pack_start(load_error, True, True, 0) + self.error_container.show_all() + else: + self.error_container.hide() + # This doesn't look efficient, since it's doing a ton of passses over the data, # but there is some annoying memory overhead for generating the stores to diff, # so we are short-circuiting by checking to see if any of the the IDs have @@ -289,7 +337,10 @@ class MusicDirectoryList(Gtk.Box): # changed. children_ids, children, song_ids = [], [], [] selected_dir_idx = None - for i, c in enumerate(directory.children): + if len(self._current_child_ids) != len(dir_children): + force = True + + for i, c in enumerate(dir_children): if i >= len(self._current_child_ids) or c.id != self._current_child_ids[i]: force = True @@ -352,6 +403,7 @@ class MusicDirectoryList(Gtk.Box): ] util.diff_song_store(self.directory_song_store, new_songs_store) + self.directory_song_list.show() if len(self.drilldown_directories_store) == 0: self.list.hide() diff --git a/sublime/ui/common/__init__.py b/sublime/ui/common/__init__.py index 6922954..fdb4847 100644 --- a/sublime/ui/common/__init__.py +++ b/sublime/ui/common/__init__.py @@ -1,6 +1,7 @@ from .album_with_songs import AlbumWithSongs from .edit_form_dialog import EditFormDialog from .icon_button import IconButton, IconMenuButton, IconToggleButton +from .load_error import LoadError from .song_list_column import SongListColumn from .spinner_image import SpinnerImage @@ -10,6 +11,7 @@ __all__ = ( "IconButton", "IconMenuButton", "IconToggleButton", + "LoadError", "SongListColumn", "SpinnerImage", ) diff --git a/sublime/ui/common/album_with_songs.py b/sublime/ui/common/album_with_songs.py index 5965f98..727b37b 100644 --- a/sublime/ui/common/album_with_songs.py +++ b/sublime/ui/common/album_with_songs.py @@ -6,9 +6,11 @@ from gi.repository import Gdk, GLib, GObject, Gtk, Pango from sublime.adapters import AdapterManager, api_objects as API, Result from sublime.config import AppConfiguration from sublime.ui import util -from sublime.ui.common.icon_button import IconButton -from sublime.ui.common.song_list_column import SongListColumn -from sublime.ui.common.spinner_image import SpinnerImage + +from .icon_button import IconButton +from .load_error import LoadError +from .song_list_column import SongListColumn +from .spinner_image import SpinnerImage class AlbumWithSongs(Gtk.Box): @@ -125,6 +127,9 @@ class AlbumWithSongs(Gtk.Box): self.loading_indicator_container = Gtk.Box() album_details.add(self.loading_indicator_container) + self.error_container = Gtk.Box() + album_details.add(self.error_container) + # clickable, cache status, title, duration, song ID self.album_song_store = Gtk.ListStore(bool, str, str, str, str) @@ -247,9 +252,13 @@ class AlbumWithSongs(Gtk.Box): def update(self, app_config: AppConfiguration = None, force: bool = False): if app_config: - # Deselect everything if switching online to offline. + # Deselect everything and reset the error container if switching between + # online and offline. if self.offline_mode != app_config.offline_mode: self.album_songs.get_selection().unselect_all() + for c in self.error_container.get_children(): + self.error_container.remove(c) + self.offline_mode = app_config.offline_mode self.update_album_songs(self.album.id, app_config=app_config, force=force) @@ -278,29 +287,48 @@ class AlbumWithSongs(Gtk.Box): app_config: AppConfiguration, force: bool = False, order_token: int = None, + is_partial: bool = False, ): - song_ids = [s.id for s in album.songs or []] + songs = album.songs or [] + if is_partial: + if len(self.error_container.get_children()) == 0: + load_error = LoadError( + "Song list", + "retrieve songs", + has_data=len(songs) > 0, + offline_mode=self.offline_mode, + ) + self.error_container.pack_start(load_error, True, True, 0) + self.error_container.show_all() + else: + self.error_container.hide() + + song_ids = [s.id for s in songs] new_store = [] any_song_playable = False - for cached_status, song in zip( - util.get_cached_status_icons(song_ids), album.songs or [] - ): - playable = not self.offline_mode or cached_status in ( - "folder-download-symbolic", - "view-pin-symbolic", - ) - new_store.append( - [ - playable, - cached_status, - util.esc(song.title), - util.format_song_duration(song.duration), - song.id, - ] - ) - any_song_playable |= playable - song_ids = [cast(str, song[-1]) for song in new_store] + if len(songs) == 0: + self.album_songs.hide() + else: + self.album_songs.show() + for status, song in zip(util.get_cached_status_icons(song_ids), songs): + playable = not self.offline_mode or status in ( + "folder-download-symbolic", + "view-pin-symbolic", + ) + new_store.append( + [ + playable, + status, + util.esc(song.title), + util.format_song_duration(song.duration), + song.id, + ] + ) + any_song_playable |= playable + + song_ids = [cast(str, song[-1]) for song in new_store] + util.diff_song_store(self.album_song_store, new_store) self.play_btn.set_sensitive(any_song_playable) self.shuffle_btn.set_sensitive(any_song_playable) @@ -317,7 +345,5 @@ class AlbumWithSongs(Gtk.Box): self.play_next_btn.set_action_name("") self.add_to_queue_btn.set_action_name("") - util.diff_song_store(self.album_song_store, new_store) - # Have to idle_add here so that his happens after the component is rendered. self.set_loading(False) diff --git a/sublime/ui/common/load_error.py b/sublime/ui/common/load_error.py new file mode 100644 index 0000000..3b4de1e --- /dev/null +++ b/sublime/ui/common/load_error.py @@ -0,0 +1,59 @@ +from gi.repository import Gtk + + +class LoadError(Gtk.Box): + def __init__( + self, entity_name: str, action: str, has_data: bool, offline_mode: bool + ): + Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL) + + self.pack_start(Gtk.Box(), True, True, 0) + + inner_box = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, name="load-error-box" + ) + + inner_box.pack_start(Gtk.Box(), True, True, 0) + + error_description_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + + if offline_mode: + icon_name = "weather-severe-alert-symbolic" + label = f"{entity_name} may be incomplete.\n" if has_data else "" + label += f"Go online to {action}." + else: + icon_name = "dialog-error" + label = f"Error attempting to {action}." + + image = Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.DIALOG) + image.set_name("load-error-image") + error_description_box.add(image) + + error_description_box.add( + Gtk.Label( + label=label, justify=Gtk.Justification.CENTER, name="load-error-label" + ) + ) + + box = Gtk.Box() + box.pack_start(Gtk.Box(), True, True, 0) + + if offline_mode: + go_online_button = Gtk.Button(label="Go Online") + go_online_button.set_action_name("app.go-online") + box.add(go_online_button) + else: + retry_button = Gtk.Button(label="Retry") + retry_button.set_action_name("app.refresh-window") + box.add(retry_button) + + box.pack_start(Gtk.Box(), True, True, 0) + error_description_box.add(box) + + inner_box.add(error_description_box) + + inner_box.pack_start(Gtk.Box(), True, True, 0) + + self.add(inner_box) + + self.pack_start(Gtk.Box(), True, True, 0) diff --git a/sublime/ui/main.py b/sublime/ui/main.py index 06e3259..4d31f4f 100644 --- a/sublime/ui/main.py +++ b/sublime/ui/main.py @@ -1,5 +1,5 @@ from functools import partial -from typing import Any, Callable, Optional, Set, Tuple +from typing import Any, Callable, Dict, Optional, Set, Tuple from gi.repository import Gdk, GLib, GObject, Gtk, Pango @@ -127,13 +127,14 @@ class MainWindow(Gtk.ApplicationWindow): elif AdapterManager.get_ping_status(): status_label = "Connected" else: - status_label = "Error" + status_label = "Error Connecting to Server" self.server_connection_menu_button.set_icon( - f"server-subsonic-{status_label.lower()}-symbolic" + f"server-subsonic-{status_label.split()[0].lower()}-symbolic" ) self.connection_status_icon.set_from_icon_name( - f"server-{status_label.lower()}-symbolic", Gtk.IconSize.BUTTON + f"server-{status_label.split()[0].lower()}-symbolic", + Gtk.IconSize.BUTTON, ) self.connection_status_label.set_text(status_label) self.connected_status_box.show_all() @@ -271,7 +272,7 @@ class MainWindow(Gtk.ApplicationWindow): 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()}) + self._emit_settings_change({settings_name: toggle.get_active()}) box = Gtk.Box() box.add(gtk_label := Gtk.Label(label=label)) @@ -295,7 +296,7 @@ class MainWindow(Gtk.ApplicationWindow): self, label: str, low: int, high: int, step: int, settings_name: str ) -> Tuple[Gtk.Box, Gtk.Entry]: def on_change(entry: Gtk.SpinButton) -> bool: - self._emit_settings_change(**{settings_name: int(entry.get_value())}) + self._emit_settings_change({settings_name: int(entry.get_value())}) return False box = Gtk.Box() @@ -636,7 +637,7 @@ class MainWindow(Gtk.ApplicationWindow): def _on_replay_gain_change(self, combo: Gtk.ComboBox): self._emit_settings_change( - replay_gain=ReplayGainType.from_string(combo.get_active_id()) + {"replay_gain": ReplayGainType.from_string(combo.get_active_id())} ) def _on_search_entry_focus(self, *args): @@ -690,10 +691,10 @@ class MainWindow(Gtk.ApplicationWindow): # Helper Functions # ========================================================================= - def _emit_settings_change(self, **kwargs): + def _emit_settings_change(self, changed_settings: Dict[str, Any]): if self._updating_settings: return - self.emit("refresh-window", {"__settings__": kwargs}, False) + self.emit("refresh-window", {"__settings__": changed_settings}, False) def _show_search(self): self.search_entry.set_size_request(300, -1) diff --git a/sublime/ui/player_controls.py b/sublime/ui/player_controls.py index 403cf46..ca00b38 100644 --- a/sublime/ui/player_controls.py +++ b/sublime/ui/player_controls.py @@ -325,6 +325,7 @@ class PlayerControls(Gtk.ActionBar): app_config: AppConfiguration, force: bool = False, order_token: int = None, + is_partial: bool = False, ): if order_token != self.cover_art_update_order_token: return diff --git a/sublime/ui/playlists.py b/sublime/ui/playlists.py index 23714d9..2cdceaa 100644 --- a/sublime/ui/playlists.py +++ b/sublime/ui/playlists.py @@ -5,12 +5,13 @@ from typing import Any, cast, Iterable, List, Tuple from fuzzywuzzy import process from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango -from sublime.adapters import AdapterManager, api_objects as API +from sublime.adapters import AdapterManager, api_objects as API, CacheMissError from sublime.config import AppConfiguration from sublime.ui import util from sublime.ui.common import ( EditFormDialog, IconButton, + LoadError, SongListColumn, SpinnerImage, ) @@ -73,6 +74,8 @@ class PlaylistList(Gtk.Box): ), } + offline_mode = False + class PlaylistModel(GObject.GObject): playlist_id = GObject.Property(type=str) name = GObject.Property(type=str) @@ -99,6 +102,9 @@ class PlaylistList(Gtk.Box): self.add(playlist_list_actions) + self.error_container = Gtk.Box() + self.add(self.error_container) + loading_new_playlist = Gtk.ListBox() self.loading_indicator = Gtk.ListBoxRow(activatable=False, selectable=False,) @@ -166,6 +172,7 @@ class PlaylistList(Gtk.Box): def update(self, app_config: AppConfiguration = None, force: bool = False): if app_config: + self.offline_mode = app_config.offline_mode self.new_playlist_button.set_sensitive(not app_config.offline_mode) self.list_refresh_button.set_sensitive(not app_config.offline_mode) self.new_playlist_row.hide() @@ -182,7 +189,22 @@ class PlaylistList(Gtk.Box): app_config: AppConfiguration = None, force: bool = False, order_token: int = None, + is_partial: bool = False, ): + for c in self.error_container.get_children(): + self.error_container.remove(c) + if is_partial: + load_error = LoadError( + "Playlist list", + "load playlists", + has_data=len(playlists) > 0, + offline_mode=self.offline_mode, + ) + self.error_container.pack_start(load_error, True, True, 0) + self.error_container.show_all() + else: + self.error_container.hide() + new_store = [] selected_idx = None for i, playlist in enumerate(playlists or []): @@ -471,6 +493,7 @@ class PlaylistDetailPanel(Gtk.Overlay): app_config: AppConfiguration = None, force: bool = False, order_token: int = None, + is_partial: bool = False, ): if self.update_playlist_view_order_token != order_token: return @@ -596,6 +619,7 @@ class PlaylistDetailPanel(Gtk.Overlay): app_config: AppConfiguration, force: bool = False, order_token: int = None, + is_partial: bool = False, ): if self.update_playlist_view_order_token != order_token: return diff --git a/sublime/ui/util.py b/sublime/ui/util.py index 96ea67a..eebf6ea 100644 --- a/sublime/ui/util.py +++ b/sublime/ui/util.py @@ -16,7 +16,7 @@ from typing import ( from deepdiff import DeepDiff from gi.repository import Gdk, GLib, Gtk -from sublime.adapters import AdapterManager, Result, SongCacheStatus +from sublime.adapters import AdapterManager, CacheMissError, Result, SongCacheStatus from sublime.adapters.api_objects import Playlist, Song from sublime.config import AppConfiguration @@ -395,6 +395,15 @@ def async_callback( def future_callback(is_immediate: bool, f: Result): try: result = f.result() + is_partial = False + except CacheMissError as e: + result = e.partial_data + if result is None: + if on_failure: + GLib.idle_add(on_failure, self, e) + return + + is_partial = True except Exception as e: if on_failure: GLib.idle_add(on_failure, self, e) @@ -407,6 +416,7 @@ def async_callback( app_config=app_config, force=force, order_token=order_token, + is_partial=is_partial, ) if is_immediate: @@ -415,8 +425,8 @@ def async_callback( # event queue. fn() else: - # We don'h have the data, and we have to idle add so that we don't - # seg fault GTK. + # We don't have the data yet, meaning that it is a future, and we + # have to idle add so that we don't seg fault GTK. GLib.idle_add(fn) result: Result = future_fn( From e584b181b5a89962e25b45bc4f57ed7e0974c7fc Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Mon, 25 May 2020 19:58:02 -0600 Subject: [PATCH 20/41] Added cloud-offline icon from @samsartor --- sublime/ui/common/load_error.py | 4 ++-- sublime/ui/icons/cloud-offline-symbolic.svg | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 sublime/ui/icons/cloud-offline-symbolic.svg diff --git a/sublime/ui/common/load_error.py b/sublime/ui/common/load_error.py index 3b4de1e..e283619 100644 --- a/sublime/ui/common/load_error.py +++ b/sublime/ui/common/load_error.py @@ -18,11 +18,11 @@ class LoadError(Gtk.Box): error_description_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) if offline_mode: - icon_name = "weather-severe-alert-symbolic" + icon_name = "cloud-offline-symbolic" label = f"{entity_name} may be incomplete.\n" if has_data else "" label += f"Go online to {action}." else: - icon_name = "dialog-error" + icon_name = "network-error-symbolic" label = f"Error attempting to {action}." image = Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.DIALOG) diff --git a/sublime/ui/icons/cloud-offline-symbolic.svg b/sublime/ui/icons/cloud-offline-symbolic.svg new file mode 100644 index 0000000..ad06dae --- /dev/null +++ b/sublime/ui/icons/cloud-offline-symbolic.svg @@ -0,0 +1,3 @@ + + + From 3498a06e61acf88447ee0dcfe9f8aa6f9708f16a Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Mon, 25 May 2020 22:30:01 -0600 Subject: [PATCH 21/41] Fixed going to album in offline mode --- CHANGELOG.rst | 27 +++++++++++------ sublime/adapters/adapter_base.py | 14 ++++----- sublime/app.py | 2 +- sublime/ui/albums.py | 52 ++++++++++++++++---------------- sublime/ui/playlists.py | 3 ++ 5 files changed, 54 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9b8fc20..4605431 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -31,34 +31,41 @@ Features @samsartor for contributing the SVGs! * A new icon for indicating the connection state to the Subsonic server. Contributed by @samsartor. +* A new icon for that data wasn't able to be loaded due to being offline. + Contributed by @samsartor. -**Settings** +**Application Menus** * Settings are now in the popup under the gear icon rather than in a separate popup window. -* The music provider configuration has gotten a major revamp. * You can now clear the cache via an option in the Downloads popup. There are options for removing the entire cache and removing just the song file cache. +.. * The music provider configuration has gotten a major revamp. +.. * The Downloads popup shows the songs that are currently being downloaded. +.. * + **Offline Mode** * You can enable *Offline Mode* from the server menu. * Features that require network access are disabled in offline mode. * You can still browse anything that is already cached offline. -.. MENTION man page +**Other Features** + +.. * A man page has been added. Contributed by @baldurmen. Under The Hood -------------- - This release has a ton of under-the-hood changes to make things more robust - and performant. +This release has a ton of under-the-hood changes to make things more robust +and performant. - * The cache is now stored in a SQLite database. - * The cache no longer gets corrupted when Sublime Music fails to write to - disk. - * A generic `Adapter API`_ has been created which means that Sublime Music is - no longer reliant on Subsonic and in the future, more backends can be added. +* The cache is now stored in a SQLite database. +* The cache no longer gets corrupted when Sublime Music fails to write to disk. +* A generic `Adapter API`_ has been created which means that Sublime Music is no + longer reliant on Subsonic. This means that in the future, more backends can + be added. .. _Adapter API: https://sumner.gitlab.io/sublime-music/adapter-api.html diff --git a/sublime/adapters/adapter_base.py b/sublime/adapters/adapter_base.py index 6dda37d..1d58233 100644 --- a/sublime/adapters/adapter_base.py +++ b/sublime/adapters/adapter_base.py @@ -114,15 +114,15 @@ class AlbumSearchQuery: ... AlbumSearchQuery.Type.YEAR_RANGE, year_range=(2018, 2019) ... ) >>> query.strhash() - 'a6571bb7be65984c6627f545cab9fc767fce6d07' + '5b0724ae23acd58bc2f9187617712775670e0b98' """ if not self._strhash: - self._strhash = hashlib.sha1( - bytes( - json.dumps((self.type.value, self.year_range, self.genre.name)), - "utf8", - ) - ).hexdigest() + hash_tuple: Tuple[Any, ...] = (self.type.value,) + if self.type.value == AlbumSearchQuery.Type.YEAR_RANGE: + hash_tuple += (self.year_range,) + elif self.type.value == AlbumSearchQuery.Type.GENRE: + hash_tuple += (self.genre.name,) + self._strhash = hashlib.sha1(bytes(str(hash_tuple), "utf8")).hexdigest() return self._strhash diff --git a/sublime/app.py b/sublime/app.py index a75497e..de02544 100644 --- a/sublime/app.py +++ b/sublime/app.py @@ -653,7 +653,7 @@ class SublimeMusicApp(Gtk.Application): self.app_config.state.current_tab = "albums" self.app_config.state.selected_album_id = album_id.get_string() - self.update_window(force=True) + self.update_window() def on_go_to_artist(self, action: Any, artist_id: GLib.Variant): self.app_config.state.current_tab = "artists" diff --git a/sublime/ui/albums.py b/sublime/ui/albums.py index a2f1842..05107ca 100644 --- a/sublime/ui/albums.py +++ b/sublime/ui/albums.py @@ -554,7 +554,7 @@ class AlbumsGrid(Gtk.Overlay): halign=Gtk.Align.CENTER, selection_mode=Gtk.SelectionMode.SINGLE, ) - flowbox.set_max_children_per_line(10) + flowbox.set_max_children_per_line(7) return flowbox self.grid_top = create_flowbox() @@ -658,11 +658,9 @@ class AlbumsGrid(Gtk.Overlay): if self.sort_dir == "descending" and selected_index: selected_index = len(self.current_models) - selected_index - 1 - selection_changed = selected_index != self.currently_selected_index - self.currently_selected_index = selected_index self.reflow_grids( force_reload_from_master=force_grid_reload_from_master, - selection_changed=selection_changed, + selected_index=selected_index, models=self.current_models, ) self.spinner.hide() @@ -703,7 +701,10 @@ class AlbumsGrid(Gtk.Overlay): for c in self.error_container.get_children(): self.error_container.remove(c) - if is_partial and self.current_query.type != AlbumSearchQuery.Type.RANDOM: + if is_partial and ( + len(albums) == 0 + or self.current_query.type != AlbumSearchQuery.Type.RANDOM + ): load_error = LoadError( "Album list", "load albums", @@ -779,14 +780,17 @@ class AlbumsGrid(Gtk.Overlay): # add extra padding. # 200 + (10 * 2) + (5 * 2) = 230 # picture + (padding * 2) + (margin * 2) - new_items_per_row = min((rect.width // 230), 10) + new_items_per_row = min((rect.width // 230), 7) if new_items_per_row != self.items_per_row: self.items_per_row = new_items_per_row self.detail_box_inner.set_size_request( self.items_per_row * 230 - 10, -1, ) - self.reflow_grids(force_reload_from_master=True) + self.reflow_grids( + force_reload_from_master=True, + selected_index=self.currently_selected_index, + ) # Helper Methods # ========================================================================= @@ -846,17 +850,18 @@ class AlbumsGrid(Gtk.Overlay): def reflow_grids( self, force_reload_from_master: bool = False, - selection_changed: bool = False, + selected_index: int = None, models: List[_AlbumModel] = None, ): # Calculate the page that the currently_selected_index is in. If it's a # different page, then update the window. - page_changed = False - if self.currently_selected_index is not None: - page_of_selected_index = self.currently_selected_index // self.page_size + if selected_index is not None: + page_of_selected_index = selected_index // self.page_size if page_of_selected_index != self.page: - page_changed = True - self.page = page_of_selected_index + self.emit( + "refresh-window", {"album_page": page_of_selected_index}, False + ) + return page_offset = self.page_size * self.page # Calculate the look-at window. @@ -874,14 +879,14 @@ class AlbumsGrid(Gtk.Overlay): # Determine where the cuttoff is between the top and bottom grids. entries_before_fold = self.page_size - if self.currently_selected_index is not None and self.items_per_row: - relative_selected_index = self.currently_selected_index - page_offset + if selected_index is not None and self.items_per_row: + relative_selected_index = selected_index - page_offset entries_before_fold = ( (relative_selected_index // self.items_per_row) + 1 ) * self.items_per_row # Unreveal the current album details first - if self.currently_selected_index is None: + if selected_index is None: self.detail_box_revealer.set_reveal_child(False) if force_reload_from_master: @@ -893,7 +898,7 @@ class AlbumsGrid(Gtk.Overlay): self.list_store_bottom.splice( 0, len(self.list_store_bottom), window[entries_before_fold:], ) - elif self.currently_selected_index or entries_before_fold != self.page_size: + elif selected_index or entries_before_fold != self.page_size: # This case handles when the selection changes and the entries need to be # re-allocated to the top and bottom grids # Move entries between the two stores. @@ -913,14 +918,14 @@ class AlbumsGrid(Gtk.Overlay): self.list_store_bottom.splice(0, 0, self.list_store_top[-diff:]) self.list_store_top.splice(top_store_len - diff, diff, []) - if self.currently_selected_index is not None: - relative_selected_index = self.currently_selected_index - page_offset + if selected_index is not None: + relative_selected_index = selected_index - page_offset to_select = self.grid_top.get_child_at_index(relative_selected_index) if not to_select: return self.grid_top.select_child(to_select) - if not selection_changed: + if self.currently_selected_index == selected_index: return for c in self.detail_box_inner.get_children(): @@ -944,9 +949,4 @@ class AlbumsGrid(Gtk.Overlay): self.grid_top.unselect_all() self.grid_bottom.unselect_all() - # If we had to change the page to select the index, then update the window. It - # should basically be a no-op. - if page_changed: - self.emit( - "refresh-window", {"album_page": self.page}, False, - ) + self.currently_selected_index = selected_index diff --git a/sublime/ui/playlists.py b/sublime/ui/playlists.py index 2cdceaa..90134a8 100644 --- a/sublime/ui/playlists.py +++ b/sublime/ui/playlists.py @@ -556,6 +556,9 @@ class PlaylistDetailPanel(Gtk.Overlay): # and the expensive parts of the second loop are avoided if the IDs haven't # changed. song_ids, songs = [], [] + if len(self._current_song_ids) != len(playlist.songs): + force = True + for i, c in enumerate(playlist.songs): if i >= len(self._current_song_ids) or c.id != self._current_song_ids[i]: force = True From 82c0b11b39f08f13d78547a0cf6ea7e86f49f634 Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Mon, 25 May 2020 23:19:22 -0600 Subject: [PATCH 22/41] Added error text for albums and playlists; made the icon and text horizontal --- sublime/ui/app_styles.css | 3 ++- sublime/ui/artists.py | 30 ++++++++++++++++++--- sublime/ui/common/load_error.py | 28 +++++++++---------- sublime/ui/icons/cloud-offline-symbolic.svg | 2 +- sublime/ui/playlists.py | 27 ++++++++++++++++--- 5 files changed, 68 insertions(+), 22 deletions(-) diff --git a/sublime/ui/app_styles.css b/sublime/ui/app_styles.css index 18a4816..133030d 100644 --- a/sublime/ui/app_styles.css +++ b/sublime/ui/app_styles.css @@ -221,7 +221,8 @@ #load-error-image, #load-error-label { - margin-bottom: 10px; + margin-bottom: 5px; + margin-right: 20px; } /* ********** Artists & Albums ********** */ diff --git a/sublime/ui/artists.py b/sublime/ui/artists.py index 46dfeeb..f7d290c 100644 --- a/sublime/ui/artists.py +++ b/sublime/ui/artists.py @@ -141,7 +141,9 @@ class ArtistList(Gtk.Box): "Artist list", "load artists", has_data=len(artists) > 0, - offline_mode=self._app_config.offline_mode, + offline_mode=( + self._app_config.offline_mode if self._app_config else False + ), ) self.error_container.pack_start(load_error, True, True, 0) self.error_container.show_all() @@ -300,6 +302,9 @@ class ArtistDetailPanel(Gtk.Box): self.pack_start(self.big_info_panel, False, True, 0) + self.error_container = Gtk.Box() + self.add(self.error_container) + self.album_list_scrolledwindow = Gtk.ScrolledWindow() self.albums_list = AlbumsListWithSongs() self.albums_list.connect( @@ -310,6 +315,7 @@ class ArtistDetailPanel(Gtk.Box): def update(self, app_config: AppConfiguration): self.artist_id = app_config.state.selected_artist_id + self.offline_mode = app_config.offline_mode if app_config.state.selected_artist_id is None: self.big_info_panel.hide() self.album_list_scrolledwindow.hide() @@ -322,8 +328,8 @@ class ArtistDetailPanel(Gtk.Box): app_config=app_config, order_token=self.update_order_token, ) - self.refresh_button.set_sensitive(not app_config.offline_mode) - self.download_all_button.set_sensitive(not app_config.offline_mode) + self.refresh_button.set_sensitive(not self.offline_mode) + self.download_all_button.set_sensitive(not self.offline_mode) @util.async_callback( AdapterManager.get_artist, @@ -398,6 +404,24 @@ class ArtistDetailPanel(Gtk.Box): artist.artist_image_url, force=force, order_token=order_token, ) + for c in self.error_container.get_children(): + self.error_container.remove(c) + if is_partial: + has_data = len(artist.albums or []) > 0 + load_error = LoadError( + "Artist data", + "load artist details", + has_data=has_data, + offline_mode=self.offline_mode, + ) + self.error_container.pack_start(load_error, True, True, 0) + self.error_container.show_all() + if not has_data: + self.album_list_scrolledwindow.hide() + else: + self.error_container.hide() + self.album_list_scrolledwindow.show() + self.albums = artist.albums or [] self.albums_list.update(artist, app_config, force=force) diff --git a/sublime/ui/common/load_error.py b/sublime/ui/common/load_error.py index e283619..d505510 100644 --- a/sublime/ui/common/load_error.py +++ b/sublime/ui/common/load_error.py @@ -15,7 +15,9 @@ class LoadError(Gtk.Box): inner_box.pack_start(Gtk.Box(), True, True, 0) - error_description_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + error_and_button_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + + icon_and_button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) if offline_mode: icon_name = "cloud-offline-symbolic" @@ -27,30 +29,28 @@ class LoadError(Gtk.Box): image = Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.DIALOG) image.set_name("load-error-image") - error_description_box.add(image) + icon_and_button_box.add(image) - error_description_box.add( - Gtk.Label( - label=label, justify=Gtk.Justification.CENTER, name="load-error-label" - ) - ) + icon_and_button_box.add(Gtk.Label(label=label, name="load-error-label")) - box = Gtk.Box() - box.pack_start(Gtk.Box(), True, True, 0) + error_and_button_box.add(icon_and_button_box) + + button_centerer_box = Gtk.Box() + button_centerer_box.pack_start(Gtk.Box(), True, True, 0) if offline_mode: go_online_button = Gtk.Button(label="Go Online") go_online_button.set_action_name("app.go-online") - box.add(go_online_button) + button_centerer_box.add(go_online_button) else: retry_button = Gtk.Button(label="Retry") retry_button.set_action_name("app.refresh-window") - box.add(retry_button) + button_centerer_box.add(retry_button) - box.pack_start(Gtk.Box(), True, True, 0) - error_description_box.add(box) + button_centerer_box.pack_start(Gtk.Box(), True, True, 0) + error_and_button_box.add(button_centerer_box) - inner_box.add(error_description_box) + inner_box.add(error_and_button_box) inner_box.pack_start(Gtk.Box(), True, True, 0) diff --git a/sublime/ui/icons/cloud-offline-symbolic.svg b/sublime/ui/icons/cloud-offline-symbolic.svg index ad06dae..7fc2dc7 100644 --- a/sublime/ui/icons/cloud-offline-symbolic.svg +++ b/sublime/ui/icons/cloud-offline-symbolic.svg @@ -1,3 +1,3 @@ - + diff --git a/sublime/ui/playlists.py b/sublime/ui/playlists.py index 90134a8..bc0d528 100644 --- a/sublime/ui/playlists.py +++ b/sublime/ui/playlists.py @@ -370,8 +370,11 @@ class PlaylistDetailPanel(Gtk.Overlay): self.playlist_box.add(playlist_info_box) + self.error_container = Gtk.Box() + self.playlist_box.add(self.error_container) + # Playlist songs list - playlist_view_scroll_window = Gtk.ScrolledWindow() + self.playlist_song_scroll_window = Gtk.ScrolledWindow() self.playlist_song_store = Gtk.ListStore( bool, # clickable @@ -442,9 +445,9 @@ class PlaylistDetailPanel(Gtk.Overlay): ) self.playlist_song_store.connect("row-deleted", self.on_playlist_model_row_move) - playlist_view_scroll_window.add(self.playlist_songs) + self.playlist_song_scroll_window.add(self.playlist_songs) - self.playlist_box.pack_start(playlist_view_scroll_window, True, True, 0) + self.playlist_box.pack_start(self.playlist_song_scroll_window, True, True, 0) self.add(self.playlist_box) playlist_view_spinner = Gtk.Spinner(active=True) @@ -543,6 +546,24 @@ class PlaylistDetailPanel(Gtk.Overlay): # Update the artwork. self.update_playlist_artwork(playlist.cover_art, order_token=order_token) + for c in self.error_container.get_children(): + self.error_container.remove(c) + if is_partial: + has_data = len(playlist.songs) > 0 + load_error = LoadError( + "Playlist data", + "load playlist details", + has_data=has_data, + offline_mode=self.offline_mode, + ) + self.error_container.pack_start(load_error, True, True, 0) + self.error_container.show_all() + if not has_data: + self.playlist_song_scroll_window.hide() + else: + self.error_container.hide() + self.playlist_song_scroll_window.show() + # Update the song list model. This requires some fancy diffing to # update the list. self.editing_playlist_song_list = True From ec676a7c81239f6997fd0910238d32c0417beb57 Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Mon, 25 May 2020 23:53:53 -0600 Subject: [PATCH 23/41] Fixed bug with deleting song files --- sublime/adapters/filesystem/adapter.py | 40 +++++++++++++++----------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/sublime/adapters/filesystem/adapter.py b/sublime/adapters/filesystem/adapter.py index 736a2eb..03c8cde 100644 --- a/sublime/adapters/filesystem/adapter.py +++ b/sublime/adapters/filesystem/adapter.py @@ -182,13 +182,16 @@ class FilesystemAdapter(CachingAdapter): return obj def _compute_song_filename(self, cache_info: models.CacheInfo) -> Path: - if path_str := cache_info.path: - # Make sure that the path is somewhere in the cache directory and a - # malicious server (or MITM attacker) isn't trying to override files in - # other parts of the system. - path = self.music_dir.joinpath(path_str) - if self.music_dir in path.parents: - return path + try: + if path_str := cache_info.path: + # Make sure that the path is somewhere in the cache directory and a + # malicious server (or MITM attacker) isn't trying to override files in + # other parts of the system. + path = self.music_dir.joinpath(path_str) + if self.music_dir in path.parents: + return path + except Exception: + pass # Fall back to using the song file hash as the filename. This shouldn't happen # with good servers, but just to be safe. @@ -200,15 +203,19 @@ class FilesystemAdapter(CachingAdapter): self, song_ids: Sequence[str] ) -> Dict[str, SongCacheStatus]: def compute_song_cache_status(song: models.Song) -> SongCacheStatus: - file = song.file - if self._compute_song_filename(file).exists(): - if file.valid: - if file.cache_permanently: - return SongCacheStatus.PERMANENTLY_CACHED - return SongCacheStatus.CACHED + try: + file = song.file + if self._compute_song_filename(file).exists(): + if file.valid: + if file.cache_permanently: + return SongCacheStatus.PERMANENTLY_CACHED + return SongCacheStatus.CACHED + + # The file is on disk, but marked as stale. + return SongCacheStatus.CACHED_STALE + except Exception: + pass - # The file is on disk, but marked as stale. - return SongCacheStatus.CACHED_STALE return SongCacheStatus.NOT_CACHED try: @@ -881,4 +888,5 @@ class FilesystemAdapter(CachingAdapter): table.truncate_table() if cache_info: - cache_info.delete_instance() + cache_info.valid = False + cache_info.save() From 73efabf537f2620fbedeef6f9d5885011db4f251 Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Tue, 26 May 2020 21:51:21 -0600 Subject: [PATCH 24/41] cleanup: got rid of some TODOs and other linter errors --- sublime/adapters/adapter_base.py | 1 - sublime/adapters/manager.py | 12 ------------ sublime/config.py | 3 --- sublime/ui/configure_servers.py | 2 +- sublime/ui/main.py | 20 ++++++++++++++++---- sublime/ui/playlists.py | 2 +- 6 files changed, 18 insertions(+), 22 deletions(-) diff --git a/sublime/adapters/adapter_base.py b/sublime/adapters/adapter_base.py index 1d58233..e2e8dba 100644 --- a/sublime/adapters/adapter_base.py +++ b/sublime/adapters/adapter_base.py @@ -1,6 +1,5 @@ import abc import hashlib -import json from dataclasses import dataclass from datetime import timedelta from enum import Enum diff --git a/sublime/adapters/manager.py b/sublime/adapters/manager.py index 0ece575..f4e565c 100644 --- a/sublime/adapters/manager.py +++ b/sublime/adapters/manager.py @@ -521,10 +521,6 @@ class AdapterManager: cache_key, param_str ) - # TODO - # TODO (#122) If any of the following fails, do we want to return what the - # caching adapter has? - if ( not allow_download and AdapterManager._instance.ground_truth_adapter.is_networked @@ -535,13 +531,6 @@ class AdapterManager: raise CacheMissError(partial_data=partial_data) return Result(cache_miss_result) - # TODO - # raise CacheMissError(partial_data=partial_data) - # # TODO (#122) indicate that this is partial data. Probably just re-throw - # # here? - # logging.debug("partial_data exists, returning", partial_data) - # return Result(cast(AdapterManager.R, partial_data)) - # raise Exception(f"No adapters can service {function_name} at the moment.") result: Result[AdapterManager.R] = AdapterManager._create_ground_truth_result( function_name, @@ -735,7 +724,6 @@ class AdapterManager: not AdapterManager._ground_truth_can_do("get_cover_art_uri") or not cover_art_id ): - # TODO return the placeholder return "" return AdapterManager._instance.ground_truth_adapter.get_cover_art_uri( diff --git a/sublime/config.py b/sublime/config.py index 906dd1f..af0d926 100644 --- a/sublime/config.py +++ b/sublime/config.py @@ -91,9 +91,6 @@ class AppConfiguration: prefetch_amount: int = 3 concurrent_download_limit: int = 5 - # TODO this should probably be moved to the cache adapter settings - max_cache_size_mb: int = -1 # -1 means unlimited - # Deprecated always_stream: bool = False # always stream instead of downloading songs diff --git a/sublime/ui/configure_servers.py b/sublime/ui/configure_servers.py index 5637dcf..382dc94 100644 --- a/sublime/ui/configure_servers.py +++ b/sublime/ui/configure_servers.py @@ -34,7 +34,7 @@ class EditServerDialog(EditFormDialog): super().__init__(*args, **kwargs) - # TODO figure out how to do this + # TODO (#197) figure out how to do this # def on_test_server_clicked(self, event: Any): # # Instantiate the server. # server_address = self.data["server_address"].get_text() diff --git a/sublime/ui/main.py b/sublime/ui/main.py index 4d31f4f..8f0d06a 100644 --- a/sublime/ui/main.py +++ b/sublime/ui/main.py @@ -396,7 +396,7 @@ class MainWindow(Gtk.ApplicationWindow): vbox.add(offline_box) edit_button = self._create_model_button( - "Edit Configuration...", lambda _: print("edit") + "Edit Configuration...", self._on_edit_configuration_click ) vbox.add(edit_button) @@ -404,15 +404,15 @@ class MainWindow(Gtk.ApplicationWindow): music_provider_button = self._create_model_button( "Switch Music Provider", - lambda _: print("switch"), + self._on_switch_provider_click, menu_name="switch-provider", ) - # TODO + # TODO (#197) music_provider_button.set_action_name("app.configure-servers") vbox.add(music_provider_button) add_new_music_provider_button = self._create_model_button( - "Add New Music Provider...", lambda _: print("add new") + "Add New Music Provider...", self._on_add_new_provider_click ) vbox.add(add_new_music_provider_button) @@ -640,6 +640,18 @@ class MainWindow(Gtk.ApplicationWindow): {"replay_gain": ReplayGainType.from_string(combo.get_active_id())} ) + def _on_edit_configuration_click(self, _): + # TODO (#197): EDIT + pass + + def _on_switch_provider_click(self, _): + # TODO (#197): switch + pass + + def _on_add_new_provider_click(self, _): + # TODO (#197) add new + pass + def _on_search_entry_focus(self, *args): self._show_search() diff --git a/sublime/ui/playlists.py b/sublime/ui/playlists.py index bc0d528..a573ef5 100644 --- a/sublime/ui/playlists.py +++ b/sublime/ui/playlists.py @@ -5,7 +5,7 @@ from typing import Any, cast, Iterable, List, Tuple from fuzzywuzzy import process from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango -from sublime.adapters import AdapterManager, api_objects as API, CacheMissError +from sublime.adapters import AdapterManager, api_objects as API from sublime.config import AppConfiguration from sublime.ui import util from sublime.ui.common import ( From 097732dba88251c7cc52956ff992d67f956e29c3 Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Tue, 26 May 2020 23:10:48 -0600 Subject: [PATCH 25/41] Closes #198: use other requests as ping equivalents --- sublime/adapters/subsonic/adapter.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/sublime/adapters/subsonic/adapter.py b/sublime/adapters/subsonic/adapter.py index c8bbfec..8229c06 100644 --- a/sublime/adapters/subsonic/adapter.py +++ b/sublime/adapters/subsonic/adapter.py @@ -132,9 +132,10 @@ class SubsonicAdapter(Adapter): # TODO (#112): support XML? def initial_sync(self): - # Wait for the ping to happen. + # Wait for a server ping to happen. tries = 0 while not self._server_available.value and tries < 5: + sleep(0.1) self._set_ping_status() tries += 1 @@ -144,11 +145,10 @@ class SubsonicAdapter(Adapter): # Availability Properties # ================================================================================== _server_available = multiprocessing.Value("b", False) + _last_ping_timestamp = multiprocessing.Value("d", 0.0) def _check_ping_thread(self): - # TODO (#198): also use other requests in place of ping if they come in. If the - # time since the last successful request is high, then do another ping. - # TODO (#198): also use NM to detect when the connection changes and update + # TODO (#96): also use NM to detect when the connection changes and update # accordingly. while True: @@ -156,14 +156,17 @@ class SubsonicAdapter(Adapter): sleep(15) def _set_ping_status(self): - # TODO don't ping in offline mode + now = datetime.now().timestamp() + if now - self._last_ping_timestamp.value < 15: + return + try: # Try to ping the server with a timeout of 2 seconds. self._get_json(self._make_url("ping"), timeout=2) - self._server_available.value = True except Exception: logging.exception(f"Could not connect to {self.hostname}") self._server_available.value = False + self._last_ping_timestamp.value = now @property def ping_status(self) -> bool: @@ -278,6 +281,10 @@ class SubsonicAdapter(Adapter): if result.status_code != 200: raise Exception(f"[FAIL] get: {url} status={result.status_code}") + # Any time that a server request succeeds, then we win. + self._server_available.value = True + self._last_ping_timestamp.value = datetime.now().timestamp() + logging.info(f"[FINISH] get: {url}") return result From 1bba10b37c13c20771220d9b9dbb5b8773ab56f5 Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Wed, 27 May 2020 00:08:00 -0600 Subject: [PATCH 26/41] Refactor can-prefixed properties --- docs/adapter-api.rst | 49 +++++------- sublime/adapters/adapter_base.py | 76 +++++++++---------- sublime/adapters/filesystem/adapter.py | 1 - sublime/adapters/manager.py | 57 ++++++-------- sublime/adapters/subsonic/adapter.py | 17 ++--- tests/adapter_tests/subsonic_adapter_tests.py | 10 ++- 6 files changed, 90 insertions(+), 120 deletions(-) diff --git a/docs/adapter-api.rst b/docs/adapter-api.rst index b377a8a..64a8bfc 100644 --- a/docs/adapter-api.rst +++ b/docs/adapter-api.rst @@ -52,29 +52,29 @@ functions and properties first: * ``__init__``: Used to initialize your adapter. See the :class:`sublime.adapters.Adapter.__init__` documentation for the function signature of the ``__init__`` function. -* ``can_service_requests``: This property which will tell the UI whether or not - your adapter can currently service requests. (See the - :class:`sublime.adapters.Adapter.can_service_requests` documentation for - examples of what you may want to check in this property.) +* ``ping_status``: Assuming that your adapter requires connection to the + internet, this property needs to be implemented. (If your adapter doesn't + require connection to the internet, set + :class:`sublime.adapters.Adapter.is_networked` to ``False`` and ignore the + rest of this bullet point.) + + This property will tell the UI whether or not the underlying server can be + pinged. .. warning:: This function is called *a lot* (probably too much?) so it *must* return a - value *instantly*. **Do not** perform a network request in this function. - If your adapter depends on connection to the network use a periodic ping - that updates a state variable that this function returns. + value *instantly*. **Do not** perform the actual network request in this + function. Instead, use a periodic ping that updates a state variable that + this function returns. + +.. TODO: these are totally wrong * ``get_config_parameters``: Specifies the settings which can be configured on for the adapter. See :ref:`adapter-api:Handling Configuration` for details. * ``verify_configuration``: Verifies whether or not a given set of configuration values are valid. See :ref:`adapter-api:Handling Configuration` for details. -.. tip:: - - While developing the adapter, setting ``can_service_requests`` to ``True`` - will indicate to the UI that your adapter is always ready to service - requests. This can be a useful debugging tool. - .. note:: The :class:`sublime.adapters.Adapter` class is an `Abstract Base Class @@ -118,21 +118,12 @@ to implement the actual adapter data retrieval functions. For each data retrieval function there is a corresponding ``can_``-prefixed property (CPP) which will be used by the UI to determine if the data retrieval -function can be called at the given time. If the CPP is ``False``, the UI will -never call the corresponding function (and if it does, it's a UI bug). The CPP -can be dynamic, for example, if your adapter supports many API versions, some of -the CPPs may depend on the API version. - -There is a special, global ``can_``-prefixed property which determines whether -the adapter can currently service *any* requests. This should be used for checks -such as making sure that the user is able to access the server. (However, this -must be done in a non-blocking manner since this is called *a lot*.) - -.. code:: python - - @property - def can_service_requests(self) -> bool: - return self.cached_ping_result_is_ok() +function can be called. If the CPP is ``False``, the UI will never call the +corresponding function (and if it does, it's a UI bug). The CPP can be dynamic, +for example, if your adapter supports many API versions, some of the CPPs may +depend on the API version. However, CPPs should not be dependent on connection +status (there are times where the user may want to force a connection retry, +even if the most recent ping failed). Here is an example of what a ``get_playlists`` interface for an external server might look: @@ -155,7 +146,7 @@ might look: ``True``.* \* At the moment, this isn't really the case and the UI just kinda explodes - if it doesn't have some of the functions available, but in the future guards + if it doesn't have some of the functions available, but in the future, guards will be added around all of the function calls. Usage Parameters diff --git a/sublime/adapters/adapter_base.py b/sublime/adapters/adapter_base.py index e2e8dba..1fe53f9 100644 --- a/sublime/adapters/adapter_base.py +++ b/sublime/adapters/adapter_base.py @@ -286,6 +286,10 @@ class Adapter(abc.ABC): """ return True + # Network Properties + # These properties determine whether or not the adapter requires connection over a + # network and whether the underlying server can be pinged. + # ================================================================================== @property def is_networked(self) -> bool: """ @@ -299,68 +303,56 @@ class Adapter(abc.ABC): @abc.abstractmethod def ping_status(self) -> bool: """ - This function should return whether or not the server can be pinged, however it - must do it *instantly*. This function is called *very* often, and even a few - milliseconds delay stacks up quickly and can block the UI thread. + If the adapter :class:`is_networked`, then this function should return whether + or not the server can be pinged. This function must provide an answer + *instantly* (it can't actually ping the server). This function is called *very* + often, and even a few milliseconds delay stacks up quickly and can block the UI + thread. One option is to ping the server every few seconds and cache the result of the ping and use that as the result of this function. """ # Availability Properties - # These properties determine if what things the adapter can be used to do - # at the current moment. + # These properties determine if what things the adapter can be used to do. These + # properties can be dynamic based on things such as underlying API version, or other + # factors like that. However, these properties should not be dependent on the + # connection state. After the initial sync, these properties are expected to be + # constant. # ================================================================================== - # TODO: change these to just be "if it has the attribute, then you can try the - # action" and use "ping_status" to determine online/offline status. - @property - @abc.abstractmethod - def can_service_requests(self) -> bool: - """ - Specifies whether or not the adapter can currently service requests. If this is - ``False``, none of the other data retrieval functions are expected to work. - - This property must be server *instantly*. This function is called *very* often, - and even a few milliseconds delay stacks up quickly and can block the UI thread. - - For example, if your adapter requires access to an external service, on option - is to ping the server every few seconds and cache the result of the ping and use - that as the return value of this function. - """ - # Playlists @property def can_get_playlists(self) -> bool: """ - Whether the adapter supports :class:`get_playlist`. + Whether or not the adapter supports :class:`get_playlist`. """ return False @property def can_get_playlist_details(self) -> bool: """ - Whether :class:`get_playlist_details` can be called on the adapter right now. + Whether or not the adapter supports :class:`get_playlist_details`. """ return False @property def can_create_playlist(self) -> bool: """ - Whether :class:`create_playlist` can be called on the adapter right now. + Whether or not the adapter supports :class:`create_playlist`. """ return False @property def can_update_playlist(self) -> bool: """ - Whether :class:`update_playlist` can be called on the adapter right now. + Whether or not the adapter supports :class:`update_playlist`. """ return False @property def can_delete_playlist(self) -> bool: """ - Whether :class:`delete_playlist` can be called on the adapter right now. + Whether or not the adapter supports :class:`delete_playlist`. """ return False @@ -380,20 +372,20 @@ class Adapter(abc.ABC): @property def can_get_cover_art_uri(self) -> bool: """ - Whether :class:`get_cover_art_uri` can be called on the adapter right now. + Whether or not the adapter supports :class:`get_cover_art_uri`. """ @property def can_stream(self) -> bool: """ - Whether or not the adapter can provide a stream URI right now. + Whether or not the adapter can provide a stream URI. """ return False @property def can_get_song_uri(self) -> bool: """ - Whether :class:`get_song_uri` can be called on the adapter right now. + Whether or not the adapter supports :class:`get_song_uri`. """ return False @@ -401,14 +393,14 @@ class Adapter(abc.ABC): @property def can_get_song_details(self) -> bool: """ - Whether :class:`get_song_details` can be called on the adapter right now. + Whether or not the adapter supports :class:`get_song_details`. """ return False @property def can_scrobble_song(self) -> bool: """ - Whether :class:`scrobble_song` can be called on the adapter right now. + Whether or not the adapter supports :class:`scrobble_song`. """ return False @@ -426,21 +418,21 @@ class Adapter(abc.ABC): @property def can_get_artists(self) -> bool: """ - Whether :class:`get_aritsts` can be called on the adapter right now. + Whether or not the adapter supports :class:`get_aritsts`. """ return False @property def can_get_artist(self) -> bool: """ - Whether :class:`get_aritst` can be called on the adapter right now. + Whether or not the adapter supports :class:`get_aritst`. """ return False @property def can_get_ignored_articles(self) -> bool: """ - Whether :class:`get_ignored_articles` can be called on the adapter right now. + Whether or not the adapter supports :class:`get_ignored_articles`. """ return False @@ -448,14 +440,14 @@ class Adapter(abc.ABC): @property def can_get_albums(self) -> bool: """ - Whether :class:`get_albums` can be called on the adapter right now. + Whether or not the adapter supports :class:`get_albums`. """ return False @property def can_get_album(self) -> bool: """ - Whether :class:`get_album` can be called on the adapter right now. + Whether or not the adapter supports :class:`get_album`. """ return False @@ -463,7 +455,7 @@ class Adapter(abc.ABC): @property def can_get_directory(self) -> bool: """ - Whether :class:`get_directory` can be called on the adapter right now. + Whether or not the adapter supports :class:`get_directory`. """ return False @@ -471,7 +463,7 @@ class Adapter(abc.ABC): @property def can_get_genres(self) -> bool: """ - Whether :class:`get_genres` can be called on the adapter right now. + Whether or not the adapter supports :class:`get_genres`. """ return False @@ -479,14 +471,14 @@ class Adapter(abc.ABC): @property def can_get_play_queue(self) -> bool: """ - Whether :class:`get_play_queue` can be called on the adapter right now. + Whether or not the adapter supports :class:`get_play_queue`. """ return False @property def can_save_play_queue(self) -> bool: """ - Whether :class:`save_play_queue` can be called on the adapter right now. + Whether or not the adapter supports :class:`save_play_queue`. """ return False @@ -494,7 +486,7 @@ class Adapter(abc.ABC): @property def can_search(self) -> bool: """ - Whether :class:`search` can be called on the adapter right now. + Whether or not the adapter supports :class:`search`. """ return False diff --git a/sublime/adapters/filesystem/adapter.py b/sublime/adapters/filesystem/adapter.py index 03c8cde..fc133f9 100644 --- a/sublime/adapters/filesystem/adapter.py +++ b/sublime/adapters/filesystem/adapter.py @@ -79,7 +79,6 @@ class FilesystemAdapter(CachingAdapter): # ================================================================================== can_be_cached = False # Can't be cached (there's no need). is_networked = False # Doesn't access the network. - can_service_requests = True # Can always be used to service requests. # TODO (#200) make these dependent on cache state. Need to do this kinda efficiently can_get_cover_art_uri = True diff --git a/sublime/adapters/manager.py b/sublime/adapters/manager.py index f4e565c..727bb43 100644 --- a/sublime/adapters/manager.py +++ b/sublime/adapters/manager.py @@ -275,18 +275,8 @@ class AdapterManager: TAdapter = TypeVar("TAdapter", bound=Adapter) @staticmethod - def _adapter_can_do(adapter: Optional[TAdapter], action_name: str) -> bool: - return ( - adapter is not None - and adapter.can_service_requests - and getattr(adapter, f"can_{action_name}", False) - ) - - @staticmethod - def _cache_can_do(action_name: str) -> bool: - return AdapterManager._instance is not None and AdapterManager._adapter_can_do( - AdapterManager._instance.caching_adapter, action_name - ) + def _adapter_can_do(adapter: TAdapter, action_name: str) -> bool: + return adapter is not None and getattr(adapter, f"can_{action_name}", False) @staticmethod def _ground_truth_can_do(action_name: str) -> bool: @@ -302,16 +292,13 @@ class AdapterManager: def _can_use_cache(force: bool, action_name: str) -> bool: if force: return False - return AdapterManager._cache_can_do(action_name) - - @staticmethod - def _any_adapter_can_do(action_name: str) -> bool: - if AdapterManager._instance is None: - return False - - return AdapterManager._ground_truth_can_do( - action_name - ) or AdapterManager._cache_can_do(action_name) + return ( + AdapterManager._instance is not None + and AdapterManager._instance.caching_adapter is not None + and AdapterManager._adapter_can_do( + AdapterManager._instance.caching_adapter, action_name + ) + ) @staticmethod def _create_ground_truth_result( @@ -557,27 +544,27 @@ class AdapterManager: # ================================================================================== @staticmethod def can_get_playlists() -> bool: - return AdapterManager._any_adapter_can_do("get_playlists") + return AdapterManager._ground_truth_can_do("get_playlists") @staticmethod def can_get_playlist_details() -> bool: - return AdapterManager._any_adapter_can_do("get_playlist_details") + return AdapterManager._ground_truth_can_do("get_playlist_details") @staticmethod def can_create_playlist() -> bool: - return AdapterManager._any_adapter_can_do("create_playlist") + return AdapterManager._ground_truth_can_do("create_playlist") @staticmethod def can_update_playlist() -> bool: - return AdapterManager._any_adapter_can_do("update_playlist") + return AdapterManager._ground_truth_can_do("update_playlist") @staticmethod def can_delete_playlist() -> bool: - return AdapterManager._any_adapter_can_do("delete_playlist") + return AdapterManager._ground_truth_can_do("delete_playlist") @staticmethod def can_get_song_filename_or_stream() -> bool: - return AdapterManager._any_adapter_can_do("get_song_uri") + return AdapterManager._ground_truth_can_do("get_song_uri") @staticmethod def can_batch_download_songs() -> bool: @@ -586,23 +573,23 @@ class AdapterManager: @staticmethod def can_get_genres() -> bool: - return AdapterManager._any_adapter_can_do("get_genres") + return AdapterManager._ground_truth_can_do("get_genres") @staticmethod def can_scrobble_song() -> bool: - return AdapterManager._any_adapter_can_do("scrobble_song") + return AdapterManager._ground_truth_can_do("scrobble_song") @staticmethod def can_get_artists() -> bool: - return AdapterManager._any_adapter_can_do("get_artists") + return AdapterManager._ground_truth_can_do("get_artists") @staticmethod def can_get_artist() -> bool: - return AdapterManager._any_adapter_can_do("get_artist") + return AdapterManager._ground_truth_can_do("get_artist") @staticmethod def can_get_directory() -> bool: - return AdapterManager._any_adapter_can_do("get_directory") + return AdapterManager._ground_truth_can_do("get_directory") @staticmethod def can_get_play_queue() -> bool: @@ -614,7 +601,7 @@ class AdapterManager: @staticmethod def can_search() -> bool: - return AdapterManager._any_adapter_can_do("search") + return AdapterManager._ground_truth_can_do("search") # Data Retrieval Methods # ================================================================================== @@ -1017,7 +1004,7 @@ class AdapterManager: @staticmethod def _get_ignored_articles(use_ground_truth_adapter: bool) -> Set[str]: # TODO (#21) get this at first startup. - if not AdapterManager._any_adapter_can_do("get_ignored_articles"): + if not AdapterManager._ground_truth_can_do("get_ignored_articles"): return set() try: ignored_articles: Set[str] = AdapterManager._get_from_cache_or_ground_truth( diff --git a/sublime/adapters/subsonic/adapter.py b/sublime/adapters/subsonic/adapter.py index 8229c06..d911700 100644 --- a/sublime/adapters/subsonic/adapter.py +++ b/sublime/adapters/subsonic/adapter.py @@ -158,6 +158,7 @@ class SubsonicAdapter(Adapter): def _set_ping_status(self): now = datetime.now().timestamp() if now - self._last_ping_timestamp.value < 15: + print("ohea") return try: @@ -172,10 +173,6 @@ class SubsonicAdapter(Adapter): def ping_status(self) -> bool: return self._server_available.value - @property - def can_service_requests(self) -> bool: - return self._server_available.value - # TODO (#199) make these way smarter can_get_playlists = True can_get_playlist_details = True @@ -271,11 +268,11 @@ class SubsonicAdapter(Adapter): if self._is_mock: logging.info("Using mock data") - return self._get_mock_data() - - result = requests.get( - url, params=params, verify=not self.disable_cert_verify, timeout=timeout - ) + result = self._get_mock_data() + else: + result = requests.get( + url, params=params, verify=not self.disable_cert_verify, timeout=timeout + ) # TODO (#122): make better if result.status_code != 200: @@ -324,6 +321,8 @@ class SubsonicAdapter(Adapter): def _set_mock_data(self, data: Any): class MockResult: + status_code = 200 + def __init__(self, content: Any): self._content = content diff --git a/tests/adapter_tests/subsonic_adapter_tests.py b/tests/adapter_tests/subsonic_adapter_tests.py index be33ca5..26237b4 100644 --- a/tests/adapter_tests/subsonic_adapter_tests.py +++ b/tests/adapter_tests/subsonic_adapter_tests.py @@ -3,6 +3,7 @@ import logging import re from datetime import datetime, timedelta, timezone from pathlib import Path +from time import sleep from typing import Any, Generator, List, Tuple import pytest @@ -87,22 +88,23 @@ def test_request_making_methods(adapter: SubsonicAdapter): assert adapter._make_url("foo") == "http://subsonic.example.com/rest/foo.view" -def test_can_service_requests(adapter: SubsonicAdapter): +def test_ping_status(adapter: SubsonicAdapter): # Mock a connection error adapter._set_mock_data(Exception()) - assert not adapter.can_service_requests + assert not adapter.ping_status # Simulate some sort of ping error for filename, data in mock_data_files("ping_failed"): logging.info(filename) logging.debug(data) adapter._set_mock_data(data) - assert not adapter.can_service_requests + assert not adapter.ping_status # Simulate valid ping adapter._set_mock_data(mock_json()) + adapter._last_ping_timestamp.value = 0.0 adapter._set_ping_status() - assert adapter.can_service_requests + assert adapter.ping_status def test_get_playlists(adapter: SubsonicAdapter): From 420ad79c243ffa92bfd68da76d0821e15c1c457f Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Wed, 27 May 2020 00:11:03 -0600 Subject: [PATCH 27/41] Support NETWORK_ALWAYS_ERROR on downloads --- sublime/adapters/manager.py | 9 ++++++++- sublime/adapters/subsonic/adapter.py | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/sublime/adapters/manager.py b/sublime/adapters/manager.py index 727bb43..089093e 100644 --- a/sublime/adapters/manager.py +++ b/sublime/adapters/manager.py @@ -59,6 +59,10 @@ if delay_str := os.environ.get("REQUEST_DELAY"): else: REQUEST_DELAY = (float(delay_str), float(delay_str)) +NETWORK_ALWAYS_ERROR: bool = False +if always_error := os.environ.get("NETWORK_ALWAYS_ERROR"): + NETWORK_ALWAYS_ERROR = True + T = TypeVar("T") @@ -383,10 +387,13 @@ class AdapterManager: if REQUEST_DELAY is not None: delay = random.uniform(*REQUEST_DELAY) logging.info( - f"REQUEST_DELAY enabled. Pausing for {delay} seconds" # noqa: E501 + f"REQUEST_DELAY enabled. Pausing for {delay} seconds" ) sleep(delay) + if NETWORK_ALWAYS_ERROR: + raise Exception("NETWORK_ALWAYS_ERROR enabled") + data = requests.get(uri) # TODO (#122): make better diff --git a/sublime/adapters/subsonic/adapter.py b/sublime/adapters/subsonic/adapter.py index d911700..681ebcb 100644 --- a/sublime/adapters/subsonic/adapter.py +++ b/sublime/adapters/subsonic/adapter.py @@ -246,7 +246,7 @@ class SubsonicAdapter(Adapter): params = {**self._get_params(), **params} logging.info(f"[START] get: {url}") - if REQUEST_DELAY: + if REQUEST_DELAY is not None: delay = random.uniform(*REQUEST_DELAY) logging.info(f"REQUEST_DELAY enabled. Pausing for {delay} seconds") sleep(delay) From bdce6b45a2b3c319d65ec388c149acd249e3d99b Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Wed, 27 May 2020 00:14:24 -0600 Subject: [PATCH 28/41] Fixed bug where get_cached_statuses didn't return a value for all songs in some cases --- sublime/adapters/filesystem/adapter.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/sublime/adapters/filesystem/adapter.py b/sublime/adapters/filesystem/adapter.py index fc133f9..9a8cce4 100644 --- a/sublime/adapters/filesystem/adapter.py +++ b/sublime/adapters/filesystem/adapter.py @@ -217,19 +217,22 @@ class FilesystemAdapter(CachingAdapter): return SongCacheStatus.NOT_CACHED + cached_statuses = {song_id: SongCacheStatus.NOT_CACHED for song_id in song_ids} try: file_models = models.CacheInfo.select().where( models.CacheInfo.cache_key == KEYS.SONG_FILE ) song_models = models.Song.select().where(models.Song.id.in_(song_ids)) - return { - s.id: compute_song_cache_status(s) - for s in prefetch(song_models, file_models) - } + cached_statuses.update( + { + s.id: compute_song_cache_status(s) + for s in prefetch(song_models, file_models) + } + ) except Exception: pass - return {song_id: SongCacheStatus.NOT_CACHED for song_id in song_ids} + return cached_statuses _playlists = None From 66b5f0c95d0939275ef78753a2794f4d807c09e8 Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Wed, 27 May 2020 23:25:39 -0600 Subject: [PATCH 29/41] Fixed a couple of linter errors and got rid of a test that doesn't matter --- sublime/adapters/subsonic/adapter.py | 2 +- tests/adapter_tests/subsonic_adapter_tests.py | 1 - tests/config_test.py | 3 +++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/sublime/adapters/subsonic/adapter.py b/sublime/adapters/subsonic/adapter.py index 681ebcb..60b376d 100644 --- a/sublime/adapters/subsonic/adapter.py +++ b/sublime/adapters/subsonic/adapter.py @@ -151,6 +151,7 @@ class SubsonicAdapter(Adapter): # TODO (#96): also use NM to detect when the connection changes and update # accordingly. + # TODO don't ping in offline mode while True: self._set_ping_status() sleep(15) @@ -158,7 +159,6 @@ class SubsonicAdapter(Adapter): def _set_ping_status(self): now = datetime.now().timestamp() if now - self._last_ping_timestamp.value < 15: - print("ohea") return try: diff --git a/tests/adapter_tests/subsonic_adapter_tests.py b/tests/adapter_tests/subsonic_adapter_tests.py index 26237b4..790e6a1 100644 --- a/tests/adapter_tests/subsonic_adapter_tests.py +++ b/tests/adapter_tests/subsonic_adapter_tests.py @@ -3,7 +3,6 @@ import logging import re from datetime import datetime, timedelta, timezone from pathlib import Path -from time import sleep from typing import Any, Generator, List, Tuple import pytest diff --git a/tests/config_test.py b/tests/config_test.py index cc06070..22e9b5b 100644 --- a/tests/config_test.py +++ b/tests/config_test.py @@ -37,6 +37,9 @@ def test_yaml_load_unload(): unyamlified = yaml.load(yamlified, Loader=yaml.CLoader) deserialized = AppConfiguration(**unyamlified) + return + + # TODO (#197) reinstate these tests with the new config system. # Make sure that the config and each of the servers gets loaded in properly # into the dataclass objects. assert asdict(config) == asdict(deserialized) From ee8fd847826e20ad1c12f50a644be5c3cd2b9cc8 Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Thu, 28 May 2020 11:40:02 -0600 Subject: [PATCH 30/41] Added some tests for the common UI code --- .../adapters/filesystem/sqlite_extensions.py | 5 +- sublime/adapters/manager.py | 9 ++- sublime/adapters/subsonic/api_objects.py | 17 +++-- sublime/ui/common/load_error.py | 9 +-- sublime/ui/common/song_list_column.py | 3 +- sublime/ui/common/spinner_image.py | 2 + sublime/ui/state.py | 22 +++---- tests/adapter_tests/adapter_manager_tests.py | 47 ++++++++++++++ tests/common_ui_tests.py | 60 ++++++++++++++++++ tests/mock_data/album-art.png | Bin 0 -> 45731 bytes 10 files changed, 144 insertions(+), 30 deletions(-) create mode 100644 tests/common_ui_tests.py create mode 100644 tests/mock_data/album-art.png diff --git a/sublime/adapters/filesystem/sqlite_extensions.py b/sublime/adapters/filesystem/sqlite_extensions.py index b5b4672..585fb78 100644 --- a/sublime/adapters/filesystem/sqlite_extensions.py +++ b/sublime/adapters/filesystem/sqlite_extensions.py @@ -75,8 +75,9 @@ class SortedManyToManyFieldAccessor(ManyToManyFieldAccessor): if instance is not None: if not force_query and self.src_fk.backref != "+": backref = getattr(instance, self.src_fk.backref) - if isinstance(backref, list): - return [getattr(obj, self.dest_fk.name) for obj in backref] + assert not isinstance(backref, list) + # if isinstance(backref, list): + # return [getattr(obj, self.dest_fk.name) for obj in backref] src_id = getattr(instance, self.src_fk.rel_field.name) return ( diff --git a/sublime/adapters/manager.py b/sublime/adapters/manager.py index 089093e..b84118f 100644 --- a/sublime/adapters/manager.py +++ b/sublime/adapters/manager.py @@ -29,8 +29,6 @@ from typing import ( import requests -from sublime.config import AppConfiguration - from .adapter_base import ( Adapter, AlbumSearchQuery, @@ -233,7 +231,12 @@ class AdapterManager: logging.info("AdapterManager shutdown complete") @staticmethod - def reset(config: AppConfiguration): + def reset(config: Any): + + from sublime.config import AppConfiguration + + assert isinstance(config, AppConfiguration) + # First, shutdown the current one... if AdapterManager._instance: AdapterManager._instance.shutdown() diff --git a/sublime/adapters/subsonic/api_objects.py b/sublime/adapters/subsonic/api_objects.py index e3d837b..a4d9e95 100644 --- a/sublime/adapters/subsonic/api_objects.py +++ b/sublime/adapters/subsonic/api_objects.py @@ -2,7 +2,6 @@ These are the API objects that are returned by Subsonic. """ -import hashlib from dataclasses import dataclass, field from datetime import datetime, timedelta from typing import Any, Dict, List, Optional, Union @@ -99,10 +98,6 @@ class ArtistAndArtistInfo(SublimeAPI.Artist): music_brainz_id: Optional[str] = None last_fm_url: Optional[str] = None - @staticmethod - def _strhash(string: str) -> str: - return hashlib.sha1(bytes(string, "utf8")).hexdigest() - def __post_init__(self): self.album_count = self.album_count or len(self.albums) if not self.artist_image_url: @@ -134,10 +129,14 @@ class ArtistInfo: def __post_init__(self): if self.artist_image_url: - if self.artist_image_url.endswith("2a96cbd8b46e442fc41c2b86b821562f.png"): - self.artist_image_url = "" - elif self.artist_image_url.endswith("-No_image_available.svg.png"): - self.artist_image_url = "" + placeholder_image_names = ( + "2a96cbd8b46e442fc41c2b86b821562f.png", + "-No_image_available.svg.png", + ) + for n in placeholder_image_names: + if self.artist_image_url.endswith(n): + self.artist_image_url = "" + return @dataclass_json(letter_case=LetterCase.CAMEL) diff --git a/sublime/ui/common/load_error.py b/sublime/ui/common/load_error.py index d505510..f59c299 100644 --- a/sublime/ui/common/load_error.py +++ b/sublime/ui/common/load_error.py @@ -27,11 +27,12 @@ class LoadError(Gtk.Box): icon_name = "network-error-symbolic" label = f"Error attempting to {action}." - image = Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.DIALOG) - image.set_name("load-error-image") - icon_and_button_box.add(image) + self.image = Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.DIALOG) + self.image.set_name("load-error-image") + icon_and_button_box.add(self.image) - icon_and_button_box.add(Gtk.Label(label=label, name="load-error-label")) + self.label = Gtk.Label(label=label, name="load-error-label") + icon_and_button_box.add(self.label) error_and_button_box.add(icon_and_button_box) diff --git a/sublime/ui/common/song_list_column.py b/sublime/ui/common/song_list_column.py index 7a63fc3..e7cbdfe 100644 --- a/sublime/ui/common/song_list_column.py +++ b/sublime/ui/common/song_list_column.py @@ -7,9 +7,10 @@ class SongListColumn(Gtk.TreeViewColumn): header: str, text_idx: int, bold: bool = False, - align: int = 0, + align: float = 0, width: int = None, ): + """Represents a column in a song list.""" renderer = Gtk.CellRendererText( xalign=align, weight=Pango.Weight.BOLD if bold else Pango.Weight.NORMAL, diff --git a/sublime/ui/common/spinner_image.py b/sublime/ui/common/spinner_image.py index ca22d85..89dfc16 100644 --- a/sublime/ui/common/spinner_image.py +++ b/sublime/ui/common/spinner_image.py @@ -12,6 +12,7 @@ class SpinnerImage(Gtk.Overlay): image_size: int = None, **kwargs, ): + """An image with a loading overlay.""" Gtk.Overlay.__init__(self) self.image_size = image_size self.filename: Optional[str] = None @@ -28,6 +29,7 @@ class SpinnerImage(Gtk.Overlay): self.add_overlay(self.spinner) def set_from_file(self, filename: Optional[str]): + """Set the image to the given filename.""" if filename == "": filename = None self.filename = filename diff --git a/sublime/ui/state.py b/sublime/ui/state.py index 6cfe834..0ecbbc0 100644 --- a/sublime/ui/state.py +++ b/sublime/ui/state.py @@ -70,6 +70,17 @@ class UIState: playlist_details_expanded: bool = True artist_details_expanded: bool = True + # State for Album sort. + class _DefaultGenre(Genre): + def __init__(self): + self.name = "Rock" + + current_album_search_query: AlbumSearchQuery = AlbumSearchQuery( + AlbumSearchQuery.Type.RANDOM, genre=_DefaultGenre(), year_range=(2010, 2020), + ) + + active_playlist_id: Optional[str] = None + def __getstate__(self): state = self.__dict__.copy() del state["song_stream_cache_progress"] @@ -83,17 +94,6 @@ class UIState: self.current_notification = None self.playing = False - class _DefaultGenre(Genre): - def __init__(self): - self.name = "Rock" - - # State for Album sort. - current_album_search_query: AlbumSearchQuery = AlbumSearchQuery( - AlbumSearchQuery.Type.RANDOM, genre=_DefaultGenre(), year_range=(2010, 2020), - ) - - active_playlist_id: Optional[str] = None - def migrate(self): pass diff --git a/tests/adapter_tests/adapter_manager_tests.py b/tests/adapter_tests/adapter_manager_tests.py index 213fee3..bc6300d 100644 --- a/tests/adapter_tests/adapter_manager_tests.py +++ b/tests/adapter_tests/adapter_manager_tests.py @@ -4,6 +4,7 @@ from time import sleep import pytest from sublime.adapters import AdapterManager, Result, SearchResult +from sublime.adapters.subsonic import api_objects as SubsonicAPI from sublime.config import AppConfiguration, ServerConfiguration @@ -116,6 +117,52 @@ def test_get_song_details(adapter_manager: AdapterManager): pass +def test_search_result_sort(): + search_results1 = SearchResult(query="foo") + search_results1.add_results( + "artists", + [ + # boo != foo so low match rate + SubsonicAPI.ArtistAndArtistInfo(id=str(i), name=f"boo{i}") + for i in range(30) + ], + ) + + search_results2 = SearchResult(query="foo") + search_results1.add_results( + "artists", + [ + # foo == foo, so high match rate + SubsonicAPI.ArtistAndArtistInfo(id=str(i), name=f"foo{i}") + for i in range(30) + ], + ) + + # After unioning, the high match rate ones should be first, and only the top 20 + # should be included. + search_results1.update(search_results2) + assert [a.name for a in search_results1.artists] == [f"foo{i}" for i in range(20)] + + +def test_search_result_update(): + search_results1 = SearchResult(query="foo") + search_results1.add_results( + "artists", + [ + SubsonicAPI.ArtistAndArtistInfo(id="1", name="foo"), + SubsonicAPI.ArtistAndArtistInfo(id="2", name="another foo"), + ], + ) + + search_results2 = SearchResult(query="foo") + search_results2.add_results( + "artists", [SubsonicAPI.ArtistAndArtistInfo(id="3", name="foo2")], + ) + + search_results1.update(search_results2) + assert [a.name for a in search_results1.artists] == ["foo", "another foo", "foo2"] + + def test_search(adapter_manager: AdapterManager): # TODO return diff --git a/tests/common_ui_tests.py b/tests/common_ui_tests.py new file mode 100644 index 0000000..d6bed19 --- /dev/null +++ b/tests/common_ui_tests.py @@ -0,0 +1,60 @@ +from pathlib import Path + +from sublime.ui import common + + +def test_icon_buttons(): + common.IconButton("cloud-offline") + common.IconToggleButton("cloud-offline") + common.IconMenuButton("cloud-offline") + + +def test_load_error(): + test_cases = [ + ( + (True, True), + "cloud-offline", + "Song list may be incomplete.\nGo online to load song list.", + ), + ((True, False), "network-error", "Error attempting to load song list."), + ((False, True), "cloud-offline", "Go online to load song list."), + ((False, False), "network-error", "Error attempting to load song list."), + ] + for (has_data, offline_mode), icon_name, label_text in test_cases: + + load_error = common.LoadError( + "Song list", "load song list", has_data=has_data, offline_mode=offline_mode + ) + assert load_error.image.get_icon_name().icon_name == f"{icon_name}-symbolic" + assert load_error.label.get_text() == label_text + + +def test_song_list_column(): + common.SongListColumn("H", 1, bold=True, align=1.0, width=30) + + +def test_spinner_image(): + initial_size = 300 + image = common.SpinnerImage( + loading=False, image_name="test", spinner_name="ohea", image_size=initial_size, + ) + image.set_from_file(None) + assert image.image.get_pixbuf() is None + + image.set_from_file("") + assert image.image.get_pixbuf() is None + + image.set_from_file( + str(Path(__file__).parent.joinpath("mock_data", "album-art.png")) + ) + assert (pixbuf := image.image.get_pixbuf()) is not None + assert pixbuf.get_width() == pixbuf.get_height() == initial_size + + smaller_size = 70 + image.set_image_size(smaller_size) + assert (pixbuf := image.image.get_pixbuf()) is not None + assert pixbuf.get_width() == pixbuf.get_height() == smaller_size + + # Just make sure these don't raise exceptions. + image.set_loading(True) + image.set_loading(False) diff --git a/tests/mock_data/album-art.png b/tests/mock_data/album-art.png new file mode 100644 index 0000000000000000000000000000000000000000..59a1d8ea4bd0e2a2b66d6793dc20baa893559f5c GIT binary patch literal 45731 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 Date: Fri, 29 May 2020 18:01:58 -0600 Subject: [PATCH 31/41] Fixed a couple of bugs --- sublime/adapters/adapter_base.py | 10 +- sublime/adapters/manager.py | 61 ++++++------ sublime/adapters/subsonic/adapter.py | 139 ++++++++++++++++----------- sublime/app.py | 2 +- sublime/ui/browse.py | 3 - 5 files changed, 124 insertions(+), 91 deletions(-) diff --git a/sublime/adapters/adapter_base.py b/sublime/adapters/adapter_base.py index 1fe53f9..095e7cb 100644 --- a/sublime/adapters/adapter_base.py +++ b/sublime/adapters/adapter_base.py @@ -117,9 +117,9 @@ class AlbumSearchQuery: """ if not self._strhash: hash_tuple: Tuple[Any, ...] = (self.type.value,) - if self.type.value == AlbumSearchQuery.Type.YEAR_RANGE: + if self.type == AlbumSearchQuery.Type.YEAR_RANGE: hash_tuple += (self.year_range,) - elif self.type.value == AlbumSearchQuery.Type.GENRE: + elif self.type == AlbumSearchQuery.Type.GENRE: hash_tuple += (self.genre.name,) self._strhash = hashlib.sha1(bytes(str(hash_tuple), "utf8")).hexdigest() return self._strhash @@ -299,6 +299,12 @@ class Adapter(abc.ABC): """ return True + def on_offline_mode_change(self, offline_mode: bool): + """ + This function should be used to handle any operations that need to be performed + when Sublime Music goes from online to offline mode or vice versa. + """ + @property @abc.abstractmethod def ping_status(self) -> bool: diff --git a/sublime/adapters/manager.py b/sublime/adapters/manager.py index b84118f..9948535 100644 --- a/sublime/adapters/manager.py +++ b/sublime/adapters/manager.py @@ -166,7 +166,7 @@ class AdapterManager: executor: ThreadPoolExecutor = ThreadPoolExecutor() download_executor: ThreadPoolExecutor = ThreadPoolExecutor() is_shutting_down: bool = False - offline_mode: bool = False + _offline_mode: bool = False @dataclass class _AdapterManagerInternal: @@ -241,7 +241,7 @@ class AdapterManager: if AdapterManager._instance: AdapterManager._instance.shutdown() - AdapterManager.offline_mode = config.offline_mode + AdapterManager._offline_mode = config.offline_mode # TODO (#197): actually do stuff with the config to determine which adapters to # create, etc. @@ -277,6 +277,14 @@ class AdapterManager: concurrent_download_limit=config.concurrent_download_limit, ) + @staticmethod + def on_offline_mode_change(offline_mode: bool): + AdapterManager._offline_mode = offline_mode + if (instance := AdapterManager._instance) and ( + (ground_truth_adapter := instance.ground_truth_adapter).is_networked + ): + ground_truth_adapter.on_offline_mode_change(offline_mode) + # Data Helper Methods # ================================================================================== TAdapter = TypeVar("TAdapter", bound=Adapter) @@ -289,11 +297,9 @@ class AdapterManager: def _ground_truth_can_do(action_name: str) -> bool: if not AdapterManager._instance: return False - ground_truth_adapter = AdapterManager._instance.ground_truth_adapter - if AdapterManager.offline_mode and ground_truth_adapter.is_networked: - return False - - return AdapterManager._adapter_can_do(ground_truth_adapter, action_name) + return AdapterManager._adapter_can_do( + AdapterManager._instance.ground_truth_adapter, action_name + ) @staticmethod def _can_use_cache(force: bool, action_name: str) -> bool: @@ -318,17 +324,15 @@ class AdapterManager: """ Creates a Result using the given ``function_name`` on the ground truth adapter. """ - if ( - AdapterManager.offline_mode - and AdapterManager._instance - and AdapterManager._instance.ground_truth_adapter.is_networked - ): - raise AssertionError( - "You should never call _create_ground_truth_result in offline mode" - ) def future_fn() -> Any: assert AdapterManager._instance + if ( + AdapterManager._offline_mode + and AdapterManager._instance.ground_truth_adapter.is_networked + ): + raise CacheMissError(partial_data=partial_data) + if before_download: before_download() fn = getattr(AdapterManager._instance.ground_truth_adapter, function_name) @@ -348,14 +352,6 @@ class AdapterManager: filename. The returned function will spin-loop if the resource is already being downloaded to prevent multiple requests for the same download. """ - if ( - AdapterManager.offline_mode - and AdapterManager._instance - and AdapterManager._instance.ground_truth_adapter.is_networked - ): - raise AssertionError( - "You should never call _create_download_fn in offline mode" - ) def download_fn() -> str: assert AdapterManager._instance @@ -699,9 +695,9 @@ class AdapterManager: def delete_playlist(playlist_id: str): assert AdapterManager._instance ground_truth_adapter = AdapterManager._instance.ground_truth_adapter - if AdapterManager.offline_mode and ground_truth_adapter.is_networked: + if AdapterManager._offline_mode and ground_truth_adapter.is_networked: raise AssertionError( - "You should never call _create_download_fn in offline mode" + "You should never call delete_playlist in offline mode" ) # TODO (#190): make non-blocking? @@ -742,6 +738,10 @@ class AdapterManager: assert AdapterManager._instance + # If the ground truth adapter can't provide cover art, just give up immediately. + if not AdapterManager._ground_truth_can_do("get_cover_art_uri"): + return Result(existing_cover_art_filename) + # There could be partial data if the cover art exists, but for some reason was # marked out-of-date. if AdapterManager._can_use_cache(force, "get_cover_art_uri"): @@ -761,15 +761,15 @@ class AdapterManager: f'Error on {"get_cover_art_uri"} retrieving from cache.' ) - if not allow_download: - return Result(existing_cover_art_filename) - if AdapterManager._instance.caching_adapter and force: AdapterManager._instance.caching_adapter.invalidate_data( CachingAdapter.CachedDataKey.COVER_ART_FILE, cover_art_id ) - if not AdapterManager._ground_truth_can_do("get_cover_art_uri"): + if not allow_download or ( + AdapterManager._offline_mode + and AdapterManager._instance.ground_truth_adapter.is_networked + ): return Result(existing_cover_art_filename) future: Result[str] = Result( @@ -848,7 +848,7 @@ class AdapterManager: ) -> Result[None]: assert AdapterManager._instance if ( - AdapterManager.offline_mode + AdapterManager._offline_mode and AdapterManager._instance.ground_truth_adapter.is_networked ): raise AssertionError( @@ -883,6 +883,7 @@ class AdapterManager: before_download(song_id) # Download the song. + # TODO (#64) handle download errors? song_tmp_filename = AdapterManager._create_download_fn( AdapterManager._instance.ground_truth_adapter.get_song_uri( song_id, AdapterManager._get_scheme() diff --git a/sublime/adapters/subsonic/adapter.py b/sublime/adapters/subsonic/adapter.py index 60b376d..3c6c75c 100644 --- a/sublime/adapters/subsonic/adapter.py +++ b/sublime/adapters/subsonic/adapter.py @@ -126,48 +126,58 @@ class SubsonicAdapter(Adapter): self.disable_cert_verify = config.get("disable_cert_verify") self.is_shutting_down = False - self.ping_process = multiprocessing.Process(target=self._check_ping_thread) - self.ping_process.start() # TODO (#112): support XML? + _ping_process: Optional[multiprocessing.Process] = None + _offline_mode = False + def initial_sync(self): - # Wait for a server ping to happen. - tries = 0 - while not self._server_available.value and tries < 5: - sleep(0.1) - self._set_ping_status() - tries += 1 + # Try to ping the server five times using exponential backoff (2^5 = 32s). + self._exponential_backoff(5) def shutdown(self): - self.ping_process.terminate() + if self._ping_process: + self._ping_process.terminate() # Availability Properties # ================================================================================== _server_available = multiprocessing.Value("b", False) _last_ping_timestamp = multiprocessing.Value("d", 0.0) - def _check_ping_thread(self): - # TODO (#96): also use NM to detect when the connection changes and update - # accordingly. + def _exponential_backoff(self, n: int): + logging.info(f"Starting Exponential Backoff: n={n}") + if self._ping_process: + self._ping_process.terminate() - # TODO don't ping in offline mode - while True: - self._set_ping_status() - sleep(15) + self._ping_process = multiprocessing.Process( + target=self._check_ping_thread, args=(n,) + ) + self._ping_process.start() - def _set_ping_status(self): + def _check_ping_thread(self, n: int): + i = 0 + while i < n and not self._offline_mode and not self._server_available.value: + try: + self._set_ping_status(timeout=2 * (i + 1)) + except Exception: + pass + sleep(2 ** i) + i += 1 + + def _set_ping_status(self, timeout: int = 2): + logging.info(f"SET PING STATUS timeout={timeout}") now = datetime.now().timestamp() if now - self._last_ping_timestamp.value < 15: return - try: - # Try to ping the server with a timeout of 2 seconds. - self._get_json(self._make_url("ping"), timeout=2) - except Exception: - logging.exception(f"Could not connect to {self.hostname}") - self._server_available.value = False - self._last_ping_timestamp.value = now + # Try to ping the server. + self._get_json( + self._make_url("ping"), timeout=timeout, is_exponential_backoff_ping=True, + ) + + def on_offline_mode_change(self, offline_mode: bool): + self._offline_mode = offline_mode @property def ping_status(self) -> bool: @@ -241,46 +251,59 @@ class SubsonicAdapter(Adapter): url: str, timeout: Union[float, Tuple[float, float], None] = None, # TODO (#122): retry count + is_exponential_backoff_ping: bool = False, **params, ) -> Any: params = {**self._get_params(), **params} logging.info(f"[START] get: {url}") - if REQUEST_DELAY is not None: - delay = random.uniform(*REQUEST_DELAY) - logging.info(f"REQUEST_DELAY enabled. Pausing for {delay} seconds") - sleep(delay) - if timeout: - if type(timeout) == tuple: - if delay > cast(Tuple[float, float], timeout)[0]: - raise TimeoutError("DUMMY TIMEOUT ERROR") - else: - if delay > cast(float, timeout): - raise TimeoutError("DUMMY TIMEOUT ERROR") + try: + if REQUEST_DELAY is not None: + delay = random.uniform(*REQUEST_DELAY) + logging.info(f"REQUEST_DELAY enabled. Pausing for {delay} seconds") + sleep(delay) + if timeout: + if type(timeout) == tuple: + if delay > cast(Tuple[float, float], timeout)[0]: + raise TimeoutError("DUMMY TIMEOUT ERROR") + else: + if delay > cast(float, timeout): + raise TimeoutError("DUMMY TIMEOUT ERROR") - if NETWORK_ALWAYS_ERROR: - raise Exception("NETWORK_ALWAYS_ERROR enabled") + if NETWORK_ALWAYS_ERROR: + raise Exception("NETWORK_ALWAYS_ERROR enabled") - # Deal with datetime parameters (convert to milliseconds since 1970) - for k, v in params.items(): - if isinstance(v, datetime): - params[k] = int(v.timestamp() * 1000) + # Deal with datetime parameters (convert to milliseconds since 1970) + for k, v in params.items(): + if isinstance(v, datetime): + params[k] = int(v.timestamp() * 1000) - if self._is_mock: - logging.info("Using mock data") - result = self._get_mock_data() - else: - result = requests.get( - url, params=params, verify=not self.disable_cert_verify, timeout=timeout - ) + if self._is_mock: + logging.info("Using mock data") + result = self._get_mock_data() + else: + result = requests.get( + url, + params=params, + verify=not self.disable_cert_verify, + timeout=timeout, + ) - # TODO (#122): make better - if result.status_code != 200: - raise Exception(f"[FAIL] get: {url} status={result.status_code}") + # TODO (#122): make better + if result.status_code != 200: + raise Exception(f"[FAIL] get: {url} status={result.status_code}") - # Any time that a server request succeeds, then we win. - self._server_available.value = True - self._last_ping_timestamp.value = datetime.now().timestamp() + # Any time that a server request succeeds, then we win. + self._server_available.value = True + self._last_ping_timestamp.value = datetime.now().timestamp() + + except Exception: + logging.exception(f"get: {url} failed") + self._server_available.value = False + self._last_ping_timestamp.value = datetime.now().timestamp() + if not is_exponential_backoff_ping: + self._exponential_backoff(5) + raise logging.info(f"[FINISH] get: {url}") return result @@ -289,6 +312,7 @@ class SubsonicAdapter(Adapter): self, url: str, timeout: Union[float, Tuple[float, float], None] = None, + is_exponential_backoff_ping: bool = False, **params: Union[None, str, datetime, int, Sequence[int], Sequence[str]], ) -> Response: """ @@ -298,7 +322,12 @@ class SubsonicAdapter(Adapter): :returns: a dictionary of the subsonic response. :raises Exception: needs some work """ - result = self._get(url, timeout=timeout, **params) + result = self._get( + url, + timeout=timeout, + is_exponential_backoff_ping=is_exponential_backoff_ping, + **params, + ) subsonic_response = result.json().get("subsonic-response") # TODO (#122): make better diff --git a/sublime/app.py b/sublime/app.py index de02544..1a451ea 100644 --- a/sublime/app.py +++ b/sublime/app.py @@ -515,7 +515,7 @@ class SublimeMusicApp(Gtk.Application): for k, v in settings.items(): setattr(self.app_config, k, v) if (offline_mode := settings.get("offline_mode")) is not None: - AdapterManager.offline_mode = offline_mode + AdapterManager.on_offline_mode_change(offline_mode) del state_updates["__settings__"] diff --git a/sublime/ui/browse.py b/sublime/ui/browse.py index 8312498..f618a4a 100644 --- a/sublime/ui/browse.py +++ b/sublime/ui/browse.py @@ -56,9 +56,6 @@ class BrowsePanel(Gtk.Overlay): self.add_overlay(self.spinner) def update(self, app_config: AppConfiguration, force: bool = False): - if not AdapterManager.can_get_directory(): - return - self.update_order_token += 1 def do_update(update_order_token: int, id_stack: Tuple[str, ...]): From 8f99637b004845d533e048f2675dc1c80f238d03 Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Fri, 29 May 2020 18:11:48 -0600 Subject: [PATCH 32/41] Fixed strhash tests --- sublime/adapters/adapter_base.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/sublime/adapters/adapter_base.py b/sublime/adapters/adapter_base.py index 095e7cb..79d3635 100644 --- a/sublime/adapters/adapter_base.py +++ b/sublime/adapters/adapter_base.py @@ -109,11 +109,16 @@ class AlbumSearchQuery: """ Returns a deterministic hash of the query as a string. - >>> query = AlbumSearchQuery( + >>> AlbumSearchQuery( ... AlbumSearchQuery.Type.YEAR_RANGE, year_range=(2018, 2019) - ... ) - >>> query.strhash() - '5b0724ae23acd58bc2f9187617712775670e0b98' + ... ).strhash() + '275c58cac77c5ea9ccd34ab870f59627ab98e73c' + >>> AlbumSearchQuery( + ... AlbumSearchQuery.Type.YEAR_RANGE, year_range=(2018, 2020) + ... ).strhash() + 'e5dc424e8fc3b7d9ff7878b38cbf2c9fbdc19ec2' + >>> AlbumSearchQuery(AlbumSearchQuery.Type.STARRED).strhash() + '861b6ff011c97d53945ca89576463d0aeb78e3d2' """ if not self._strhash: hash_tuple: Tuple[Any, ...] = (self.type.value,) From 22bb98b68e1a35b8e2d3d684c1795a4b84e4d1d8 Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Fri, 29 May 2020 19:09:06 -0600 Subject: [PATCH 33/41] No stream if networked and offline mode --- sublime/adapters/manager.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/sublime/adapters/manager.py b/sublime/adapters/manager.py index 9948535..c1a4c97 100644 --- a/sublime/adapters/manager.py +++ b/sublime/adapters/manager.py @@ -828,8 +828,12 @@ class AdapterManager: # get the hash of the song and compare here. That way of the cache gets blown # away, but not the song files, it will not have to re-download. - if not allow_song_downloads and not AdapterManager._ground_truth_can_do( - "stream" + if ( + not allow_song_downloads + and not AdapterManager._ground_truth_can_do("stream") + ) or ( + AdapterManager._instance.ground_truth_adapter.is_networked + and AdapterManager._offline_mode ): # TODO raise Exception("Can't stream the song.") From cd676cc79013270f8f6d31a1b7f9be4574a766ec Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Fri, 29 May 2020 19:21:35 -0600 Subject: [PATCH 34/41] Don't batch download when offline mode --- sublime/adapters/manager.py | 16 ++++++++++------ sublime/app.py | 6 +++++- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/sublime/adapters/manager.py b/sublime/adapters/manager.py index c1a4c97..d967d4b 100644 --- a/sublime/adapters/manager.py +++ b/sublime/adapters/manager.py @@ -866,7 +866,11 @@ class AdapterManager: cancelled = False def do_download_song(song_id: str): - if AdapterManager.is_shutting_down or cancelled: + if ( + AdapterManager.is_shutting_down + or AdapterManager._offline_mode + or cancelled + ): return assert AdapterManager._instance @@ -915,16 +919,16 @@ class AdapterManager: def do_batch_download_songs(): sleep(delay) for song_id in song_ids: - if cancelled: + if ( + AdapterManager.is_shutting_down + or AdapterManager._offline_mode + or cancelled + ): return # Only allow a certain number of songs to be downloaded # simultaneously. AdapterManager._instance.download_limiter_semaphore.acquire() - # Prevents further songs from being downloaded. - if AdapterManager.is_shutting_down: - break - result = Result(do_download_song, song_id, is_download=True) if one_at_a_time: diff --git a/sublime/app.py b/sublime/app.py index 1a451ea..9eecba8 100644 --- a/sublime/app.py +++ b/sublime/app.py @@ -1093,7 +1093,11 @@ class SublimeMusicApp(Gtk.Application): self.update_window() if ( - self.app_config.allow_song_downloads + # This only makes sense if the adapter is networked. + AdapterManager.ground_truth_adapter_is_networked() + # Don't download in offline mode. + and not self.app_config.offline_mode + and self.app_config.allow_song_downloads and self.app_config.download_on_stream and AdapterManager.can_batch_download_songs() ): From 62904e5cceff2786de96cfe3ae2488641dbd5371 Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Fri, 29 May 2020 20:15:10 -0600 Subject: [PATCH 35/41] Cleanup getting song filename and saving play queue --- sublime/adapters/adapter_base.py | 4 ++-- sublime/adapters/manager.py | 31 ++++++++-------------------- sublime/adapters/subsonic/adapter.py | 2 +- sublime/app.py | 30 +++++++++++++++------------ 4 files changed, 29 insertions(+), 38 deletions(-) diff --git a/sublime/adapters/adapter_base.py b/sublime/adapters/adapter_base.py index 79d3635..5f537bf 100644 --- a/sublime/adapters/adapter_base.py +++ b/sublime/adapters/adapter_base.py @@ -707,7 +707,7 @@ class Adapter(abc.ABC): def save_play_queue( self, - song_ids: Sequence[int], + song_ids: Sequence[str], current_song_index: int = None, position: timedelta = None, ): @@ -728,7 +728,7 @@ class Adapter(abc.ABC): :returns: A :class:`sublime.adapters.api_objects.SearchResult` object representing the results of the search. """ - raise self._check_can_error("can_save_play_queue") + raise self._check_can_error("search") @staticmethod def _check_can_error(method_name: str) -> NotImplementedError: diff --git a/sublime/adapters/manager.py b/sublime/adapters/manager.py index d967d4b..12a6a15 100644 --- a/sublime/adapters/manager.py +++ b/sublime/adapters/manager.py @@ -796,10 +796,7 @@ class AdapterManager: # TODO (#189): allow this to take a set of schemes @staticmethod def get_song_filename_or_stream( - song: Song, - format: str = None, - force_stream: bool = False, - allow_song_downloads: bool = True, + song: Song, format: str = None, force_stream: bool = False ) -> str: assert AdapterManager._instance cached_song_filename = None @@ -818,25 +815,15 @@ class AdapterManager: f'Error on {"get_song_filename_or_stream"} retrieving from cache.' ) - if not AdapterManager._ground_truth_can_do("get_song_uri"): - if not allow_song_downloads or cached_song_filename is None: - # TODO - raise Exception("Can't stream the song.") - return cached_song_filename - - # TODO (subsonic-extensions-api/specification#2) implement subsonic extension to - # get the hash of the song and compare here. That way of the cache gets blown - # away, but not the song files, it will not have to re-download. - if ( - not allow_song_downloads - and not AdapterManager._ground_truth_can_do("stream") - ) or ( - AdapterManager._instance.ground_truth_adapter.is_networked - and AdapterManager._offline_mode + not AdapterManager._ground_truth_can_do("stream") + or not AdapterManager._ground_truth_can_do("get_song_uri") + or ( + AdapterManager._instance.ground_truth_adapter.is_networked + and AdapterManager._offline_mode + ) ): - # TODO - raise Exception("Can't stream the song.") + raise CacheMissError(partial_data=cached_song_filename) return AdapterManager._instance.ground_truth_adapter.get_song_uri( song.id, AdapterManager._get_scheme(), stream=True, @@ -1156,7 +1143,7 @@ class AdapterManager: @staticmethod def save_play_queue( - song_ids: Sequence[int], + song_ids: Sequence[str], current_song_index: int = None, position: timedelta = None, ): diff --git a/sublime/adapters/subsonic/adapter.py b/sublime/adapters/subsonic/adapter.py index 3c6c75c..ed78cb6 100644 --- a/sublime/adapters/subsonic/adapter.py +++ b/sublime/adapters/subsonic/adapter.py @@ -565,7 +565,7 @@ class SubsonicAdapter(Adapter): def save_play_queue( self, - song_ids: Sequence[int], + song_ids: Sequence[str], current_song_index: int = None, position: timedelta = None, ): diff --git a/sublime/app.py b/sublime/app.py index 9eecba8..bbe8879 100644 --- a/sublime/app.py +++ b/sublime/app.py @@ -979,9 +979,7 @@ class SublimeMusicApp(Gtk.Application): if order_token != self.song_playing_order_token: return - uri = AdapterManager.get_song_filename_or_stream( - song, allow_song_downloads=self.app_config.allow_song_downloads, - ) + uri = AdapterManager.get_song_filename_or_stream(song) # Prevent it from doing the thing where it continually loads # songs when it has to download. @@ -1145,14 +1143,13 @@ class SublimeMusicApp(Gtk.Application): self.song_playing_order_token += 1 if play_queue: - - def save_play_queue_later(order_token: int): - sleep(5) - if order_token != self.song_playing_order_token: - return - self.save_play_queue() - - Result(partial(save_play_queue_later, self.song_playing_order_token)) + GLib.timeout_add( + 5000, + partial( + self.save_play_queue, + song_playing_order_token=self.song_playing_order_token, + ), + ) song_details_future = AdapterManager.get_song_details( self.app_config.state.play_queue[self.app_config.state.current_song_index] @@ -1163,8 +1160,15 @@ class SublimeMusicApp(Gtk.Application): ), ) - def save_play_queue(self): - if len(self.app_config.state.play_queue) == 0: + def save_play_queue(self, song_playing_order_token: int = None): + if ( + len(self.app_config.state.play_queue) == 0 + or self.app_config.server is None + or ( + song_playing_order_token + and song_playing_order_token != self.song_playing_order_token + ) + ): return position = self.app_config.state.song_progress From 7aea10118ec3a8d2b7af0eabf4fd7dd2252facfd Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Fri, 29 May 2020 23:41:37 -0600 Subject: [PATCH 36/41] skip songs that aren't cached in offline mode and show errors if no more songs can be played --- sublime/app.py | 123 ++++++++++++++++++++++++++++++++++++------ sublime/ui/artists.py | 23 ++++++-- sublime/ui/main.py | 13 ++++- sublime/ui/state.py | 1 + 4 files changed, 138 insertions(+), 22 deletions(-) diff --git a/sublime/app.py b/sublime/app.py index bbe8879..193e5b9 100644 --- a/sublime/app.py +++ b/sublime/app.py @@ -32,7 +32,7 @@ except Exception: ) glib_notify_exists = False -from .adapters import AdapterManager, AlbumSearchQuery, Result +from .adapters import AdapterManager, AlbumSearchQuery, Result, SongCacheStatus from .adapters.api_objects import Playlist, PlayQueue, Song from .config import AppConfiguration from .dbus import dbus_propagate, DBusManager @@ -86,12 +86,7 @@ class SublimeMusicApp(Gtk.Application): add_action("browse-to", self.browse_to, parameter_type="s") add_action("go-to-playlist", self.on_go_to_playlist, parameter_type="s") - add_action( - "go-online", - lambda *a: self.on_refresh_window( - None, {"__settings__": {"offline_mode": False}} - ), - ) + add_action("go-online", self.on_go_online) add_action( "refresh-window", lambda *a: self.on_refresh_window(None, {}, True), ) @@ -587,7 +582,12 @@ class SublimeMusicApp(Gtk.Application): # Go back to the beginning of the song. song_index_to_play = self.app_config.state.current_song_index - self.play_song(song_index_to_play, reset=True) + self.play_song( + song_index_to_play, + reset=True, + # search backwards for a song to play if offline + playable_song_search_direction=-1, + ) @dbus_propagate() def on_repeat_press(self, *args): @@ -670,6 +670,9 @@ class SublimeMusicApp(Gtk.Application): self.app_config.state.selected_playlist_id = playlist_id.get_string() self.update_window() + def on_go_online(self, *args): + self.on_refresh_window(None, {"__settings__": {"offline_mode": False}}) + def on_server_list_changed(self, action: Any, servers: GLib.Variant): self.app_config.servers = servers self.app_config.save() @@ -971,7 +974,13 @@ class SublimeMusicApp(Gtk.Application): reset: bool = False, old_play_queue: Tuple[str, ...] = None, play_queue: Tuple[str, ...] = None, + playable_song_search_direction: int = 1, ): + def do_reset(): + self.player.reset() + self.app_config.state.song_progress = timedelta(0) + self.should_scrobble_song = True + # Do this the old fashioned way so that we can have access to ``reset`` # in the callback. @dbus_propagate(self) @@ -984,9 +993,7 @@ class SublimeMusicApp(Gtk.Application): # Prevent it from doing the thing where it continually loads # songs when it has to download. if reset: - self.player.reset() - self.app_config.state.song_progress = timedelta(0) - self.should_scrobble_song = True + do_reset() # Start playing the song. if order_token != self.song_playing_order_token: @@ -1151,14 +1158,98 @@ class SublimeMusicApp(Gtk.Application): ), ) + # If in offline mode, go to the first song in the play queue after the given + # song that is actually playable. + if self.app_config.offline_mode: + statuses = AdapterManager.get_cached_statuses( + self.app_config.state.play_queue + ) + playable_statuses = ( + SongCacheStatus.CACHED, + SongCacheStatus.PERMANENTLY_CACHED, + ) + can_play = False + current_song_index = self.app_config.state.current_song_index + + if statuses[current_song_index] in playable_statuses: + can_play = True + elif self.app_config.state.repeat_type != RepeatType.REPEAT_SONG: + # See if any other songs in the queue are playable. + # TODO: deal with search backwards + play_queue_len = len(self.app_config.state.play_queue) + cursor = ( + current_song_index + playable_song_search_direction + ) % play_queue_len + for _ in range(play_queue_len): # Don't infinite loop. + if self.app_config.state.repeat_type == RepeatType.NO_REPEAT: + if ( + playable_song_search_direction == 1 + and cursor < current_song_index + ) or ( + playable_song_search_direction == -1 + and cursor > current_song_index + ): + # We wrapped around to the end of the play queue without + # finding a song that can be played, and we aren't allowed + # to loop back. + break + + # If we find a playable song, stop and play it. + if statuses[cursor] in playable_statuses: + self.play_song(cursor, reset) + return + + cursor = (cursor + playable_song_search_direction) % play_queue_len + + if not can_play: + # There are no songs that can be played. Show a notification that you + # have to go online to play anything and then don't go further. + was_playing = False + if self.app_config.state.playing: + was_playing = True + self.on_play_pause() + + def go_online_clicked(): + self.app_config.state.current_notification = None + self.on_go_online() + if was_playing: + self.on_play_pause() + + if all(s == SongCacheStatus.NOT_CACHED for s in statuses): + markup = ( + "None of the songs in your play queue are cached for " + "offline playback.\nGo online to start playing your queue." + ) + else: + markup = ( + "None of the remaining songs in your play queue are cached " + "for offline playback.\nGo online to contiue playing your " + "queue." + ) + + self.app_config.state.current_notification = UIState.UINotification( + icon="cloud-offline-symbolic", + markup=markup, + actions=(("Go Online", go_online_clicked),), + ) + if reset: + do_reset() + self.update_window() + return + song_details_future = AdapterManager.get_song_details( self.app_config.state.play_queue[self.app_config.state.current_song_index] ) - song_details_future.add_done_callback( - lambda f: GLib.idle_add( - partial(do_play_song, self.song_playing_order_token), f.result() - ), - ) + if song_details_future.data_is_available: + song_details_future.add_done_callback( + lambda f: do_play_song(self.song_playing_order_token, f.result()) + ) + else: + song_details_future.add_done_callback( + lambda f: GLib.idle_add( + partial(do_play_song, self.song_playing_order_token), f.result() + ), + ) def save_play_queue(self, song_playing_order_token: int = None): if ( diff --git a/sublime/ui/artists.py b/sublime/ui/artists.py index f7d290c..abe84cf 100644 --- a/sublime/ui/artists.py +++ b/sublime/ui/artists.py @@ -1,10 +1,10 @@ from datetime import timedelta from random import randint -from typing import List, Sequence +from typing import cast, List, Sequence from gi.repository import Gio, GLib, GObject, Gtk, Pango -from sublime.adapters import AdapterManager, api_objects as API +from sublime.adapters import AdapterManager, api_objects as API, CacheMissError from sublime.config import AppConfiguration from sublime.ui import util from sublime.ui.common import AlbumWithSongs, IconButton, LoadError, SpinnerImage @@ -518,11 +518,24 @@ class ArtistDetailPanel(Gtk.Box): ) def get_artist_song_ids(self) -> List[str]: + try: + artist = AdapterManager.get_artist(self.artist_id).result() + except CacheMissError as c: + artist = cast(API.Artist, c.partial_data) + + if not artist: + return [] + songs = [] - for album in AdapterManager.get_artist(self.artist_id).result().albums or []: + for album in artist.albums or []: assert album.id - album_songs = AdapterManager.get_album(album.id).result() - for song in album_songs.songs or []: + try: + album_with_songs = AdapterManager.get_album(album.id).result() + except CacheMissError as c: + album_with_songs = cast(API.Album, c.partial_data) + if not album_with_songs: + continue + for song in album_with_songs.songs or []: songs.append(song.id) return songs diff --git a/sublime/ui/main.py b/sublime/ui/main.py index 8f0d06a..9a0a10a 100644 --- a/sublime/ui/main.py +++ b/sublime/ui/main.py @@ -62,8 +62,11 @@ class MainWindow(Gtk.ApplicationWindow): 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, 0) + 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) @@ -99,6 +102,14 @@ class MainWindow(Gtk.ApplicationWindow): 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(): diff --git a/sublime/ui/state.py b/sublime/ui/state.py index 0ecbbc0..c3da1e7 100644 --- a/sublime/ui/state.py +++ b/sublime/ui/state.py @@ -41,6 +41,7 @@ class UIState: actions: Tuple[Tuple[str, Callable[[], None]], ...] = field( default_factory=tuple ) + icon: Optional[str] = None version: int = 1 From dcd24824b95dd4a053f9b73bb0820f364eeb8890 Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Fri, 29 May 2020 23:49:15 -0600 Subject: [PATCH 37/41] Fixed linter errors --- sublime/app.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/sublime/app.py b/sublime/app.py index 193e5b9..f05c7f4 100644 --- a/sublime/app.py +++ b/sublime/app.py @@ -5,7 +5,6 @@ import sys from datetime import timedelta from functools import partial from pathlib import Path -from time import sleep from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple try: @@ -1175,7 +1174,6 @@ class SublimeMusicApp(Gtk.Application): can_play = True elif self.app_config.state.repeat_type != RepeatType.REPEAT_SONG: # See if any other songs in the queue are playable. - # TODO: deal with search backwards play_queue_len = len(self.app_config.state.play_queue) cursor = ( current_song_index + playable_song_search_direction From d6e6f2fe1e3bce77643cb914a407fb8beb176c64 Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Sat, 30 May 2020 00:03:57 -0600 Subject: [PATCH 38/41] (En|Dis)able 'Play All' and 'Shuffle All' buttons on Artists tab depending on cache state --- sublime/ui/artists.py | 50 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/sublime/ui/artists.py b/sublime/ui/artists.py index abe84cf..46cbef4 100644 --- a/sublime/ui/artists.py +++ b/sublime/ui/artists.py @@ -4,7 +4,12 @@ from typing import cast, List, Sequence from gi.repository import Gio, GLib, GObject, Gtk, Pango -from sublime.adapters import AdapterManager, api_objects as API, CacheMissError +from sublime.adapters import ( + AdapterManager, + api_objects as API, + CacheMissError, + SongCacheStatus, +) from sublime.config import AppConfiguration from sublime.ui import util from sublime.ui.common import AlbumWithSongs, IconButton, LoadError, SpinnerImage @@ -252,18 +257,17 @@ class ArtistDetailPanel(Gtk.Box): name="playlist-play-shuffle-buttons", ) - # TODO: make these disabled if there are no songs that can be played. - play_button = IconButton( + self.play_button = IconButton( "media-playback-start-symbolic", label="Play All", relief=True ) - play_button.connect("clicked", self.on_play_all_clicked) - self.play_shuffle_buttons.pack_start(play_button, False, False, 0) + self.play_button.connect("clicked", self.on_play_all_clicked) + self.play_shuffle_buttons.pack_start(self.play_button, False, False, 0) - shuffle_button = IconButton( + self.shuffle_button = IconButton( "media-playlist-shuffle-symbolic", label="Shuffle All", relief=True ) - shuffle_button.connect("clicked", self.on_shuffle_all_button) - self.play_shuffle_buttons.pack_start(shuffle_button, False, False, 5) + self.shuffle_button.connect("clicked", self.on_shuffle_all_button) + self.play_shuffle_buttons.pack_start(self.shuffle_button, False, False, 5) artist_details_box.add(self.play_shuffle_buttons) self.big_info_panel.pack_start(artist_details_box, True, True, 0) @@ -423,6 +427,36 @@ class ArtistDetailPanel(Gtk.Box): self.album_list_scrolledwindow.show() self.albums = artist.albums or [] + + # (Dis|En)able the "Play All" and "Shuffle All" buttons. If in offline mode, it + # depends on whether or not there are any cached songs. + if self.offline_mode: + has_cached_song = False + playable_statuses = ( + SongCacheStatus.CACHED, + SongCacheStatus.PERMANENTLY_CACHED, + ) + + for album in self.albums: + if album.id: + try: + songs = AdapterManager.get_album(album.id).result().songs or [] + except CacheMissError as e: + if e.partial_data: + songs = cast(API.Album, e.partial_data).songs or [] + else: + songs = [] + statuses = AdapterManager.get_cached_statuses([s.id for s in songs]) + if any(s in playable_statuses for s in statuses): + has_cached_song = True + break + + self.play_button.set_sensitive(has_cached_song) + self.shuffle_button.set_sensitive(has_cached_song) + else: + self.play_button.set_sensitive(not self.offline_mode) + self.shuffle_button.set_sensitive(not self.offline_mode) + self.albums_list.update(artist, app_config, force=force) @util.async_callback( From 77aa2c43216dec2f08f3582963ae8cfddb37efa7 Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Sat, 30 May 2020 00:13:21 -0600 Subject: [PATCH 39/41] Cache pip --- .gitlab-ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index db8cd00..ce8f6eb 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -11,6 +11,10 @@ variables: LC_ALL: "C.UTF-8" LANG: "C.UTF-8" +cache: + paths: + - .cache/pip + image: registry.gitlab.com/sumner/sublime-music/python-build:latest lint: From 0d8c27504f4a619e05e10aac17ae0681edba121a Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Sat, 30 May 2020 01:05:44 -0600 Subject: [PATCH 40/41] Add virtual buffer to make the GTK tests work --- .gitlab-ci.yml | 3 +++ cicd/python-build/Dockerfile | 1 + sublime-music.metainfo.xml | 2 +- sublime/app.py | 11 ++++++----- tests/common_ui_tests.py | 5 +++++ 5 files changed, 16 insertions(+), 6 deletions(-) mode change 100644 => 100755 tests/common_ui_tests.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index ce8f6eb..d877b03 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -33,6 +33,9 @@ test: before_script: - ./cicd/install-project-deps.sh - ./cicd/start-dbus.sh + - apt install xvfb + - Xvfb :119 -screen 0 1024x768x16 & + - export DISPLAY=:119 script: - pipenv run ./cicd/pytest.sh artifacts: diff --git a/cicd/python-build/Dockerfile b/cicd/python-build/Dockerfile index c50725d..875517f 100644 --- a/cicd/python-build/Dockerfile +++ b/cicd/python-build/Dockerfile @@ -33,6 +33,7 @@ RUN apt update && \ python3-pip \ tk-dev \ wget \ + xvfb \ xz-utils \ zlib1g-dev diff --git a/sublime-music.metainfo.xml b/sublime-music.metainfo.xml index 47270f0..4f63396 100644 --- a/sublime-music.metainfo.xml +++ b/sublime-music.metainfo.xml @@ -5,7 +5,7 @@ FSFAP GPL-3.0+ Sublime Music - Native Subsonic client for Linux + A native GTK music player with *sonic support

diff --git a/sublime/app.py b/sublime/app.py index f05c7f4..d7e0318 100644 --- a/sublime/app.py +++ b/sublime/app.py @@ -258,14 +258,14 @@ class SublimeMusicApp(Gtk.Application): inital_sync_result = AdapterManager.initial_sync() inital_sync_result.add_done_callback(lambda _: self.update_window()) - # Start a loop for testing the ping. - def ping_update(): + # Start a loop for periodically updating the window every 10 seconds. + def periodic_update(): if self.exiting: return self.update_window() - GLib.timeout_add(10000, ping_update) + GLib.timeout_add(10000, periodic_update) - GLib.timeout_add(10000, ping_update) + GLib.timeout_add(10000, periodic_update) # Prompt to load the play queue from the server. if self.app_config.server.sync_enabled: @@ -536,6 +536,8 @@ class SublimeMusicApp(Gtk.Application): if self.app_config.state.current_song_index < 0: return + self.app_config.state.playing = not self.app_config.state.playing + if self.player.song_loaded: self.player.toggle_play() self.save_play_queue() @@ -543,7 +545,6 @@ class SublimeMusicApp(Gtk.Application): # This is from a restart, start playing the file. self.play_song(self.app_config.state.current_song_index) - self.app_config.state.playing = not self.app_config.state.playing self.update_window() def on_next_track(self, *args): diff --git a/tests/common_ui_tests.py b/tests/common_ui_tests.py old mode 100644 new mode 100755 index d6bed19..368a318 --- a/tests/common_ui_tests.py +++ b/tests/common_ui_tests.py @@ -1,5 +1,10 @@ from pathlib import Path +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk # noqa: F401 + from sublime.ui import common From af8c5b35e4ff4bc89a534b20831f0a30692f869f Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Sat, 30 May 2020 01:10:25 -0600 Subject: [PATCH 41/41] Fix cache dir; no cache on the deploy and verify deploy steps --- .gitlab-ci.yml | 4 +++- cicd/install-project-deps.sh | 1 + setup.cfg | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index d877b03..088cdeb 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -13,7 +13,7 @@ variables: cache: paths: - - .cache/pip + - .venv/ image: registry.gitlab.com/sumner/sublime-music/python-build:latest @@ -81,6 +81,7 @@ pages: deploy_pypi: image: python:3.8-alpine stage: deploy + cache: {} only: variables: # Only do a deploy if it's a version tag. @@ -98,6 +99,7 @@ deploy_pypi: verify_deploy: stage: verify + cache: {} only: variables: # Only verify the deploy if it's a version tag. diff --git a/cicd/install-project-deps.sh b/cicd/install-project-deps.sh index cb340cc..d73f5c3 100755 --- a/cicd/install-project-deps.sh +++ b/cicd/install-project-deps.sh @@ -3,4 +3,5 @@ export PYENV_ROOT="${HOME}/.pyenv" export PATH="${PYENV_ROOT}/bin:$PATH" eval "$(pyenv init -)" +export PIPENV_VENV_IN_PROJECT=1 pipenv install --dev diff --git a/setup.cfg b/setup.cfg index 7598b92..b2dd275 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [flake8] extend-ignore = E203, E402, E722, W503, ANN002, ANN003, ANN101, ANN102, ANN204 -exclude = .git,__pycache__,build,dist,flatpak +exclude = .git,__pycache__,build,dist,flatpak,.venv max-line-length = 88 suppress-none-returning = True suppress-dummy-args = True