diff --git a/cozy/app_controller.py b/cozy/app_controller.py index 97f58a82..2062a27e 100644 --- a/cozy/app_controller.py +++ b/cozy/app_controller.py @@ -37,6 +37,7 @@ from cozy.view_model.search_view_model import SearchViewModel from cozy.view_model.settings_view_model import SettingsViewModel from cozy.view_model.sleep_timer_view_model import SleepTimerViewModel +from cozy.view_model.storages_view_model import StoragesViewModel class AppController(metaclass=Singleton): @@ -75,7 +76,7 @@ def __init__(self, gtk_app, main_window_builder, main_window): self.library_view_model.add_listener(self._on_open_view) self.library_view_model.add_listener(self._on_library_view_event) self.playback_control_view_model.add_listener(self._on_open_view) - self.headerbar_view_model.add_listener(self._on_open_view) + self.headerbar_view_model.add_listener(self._on_working_event) self.app_view_model.add_listener(self._on_app_view_event) self.main_window.add_listener(self._on_main_window_event) @@ -108,6 +109,7 @@ def configure_inject(self, binder): binder.bind_to_constructor(ToastNotifier, lambda: ToastNotifier()) binder.bind_to_constructor(AppViewModel, lambda: AppViewModel()) binder.bind_to_constructor(SettingsViewModel, lambda: SettingsViewModel()) + binder.bind_to_constructor(StoragesViewModel, lambda: StoragesViewModel()) def open_author(self, author: str): self.library_view_model.library_view_mode = LibraryViewMode.AUTHOR @@ -149,10 +151,12 @@ def _on_app_view_event(self, event: str, data): if event == "view": self.headerbar_view_model.set_view(data) - def _on_main_window_event(self, event: str, data): + def _on_working_event(self, event: str, data) -> None: if event == "working": self.book_detail_view_model.lock_ui = data self.settings_view_model.lock_ui = data + + def _on_main_window_event(self, event: str, data): if event == "open_view": self._on_open_view(data, None) diff --git a/cozy/model/settings.py b/cozy/model/settings.py index c9202233..b3487578 100644 --- a/cozy/model/settings.py +++ b/cozy/model/settings.py @@ -30,15 +30,19 @@ def last_played_book(self) -> Book | None: try: return self._db_object.last_played_book except peewee.DoesNotExist: - log.warning("last_played_book references an non existent object. Setting last_played_book to None.") - reporter.warning("settings_model", - "last_played_book references an non existent object. Setting last_played_book to None.") + log.warning( + "last_played_book references an non existent object. Setting last_played_book to None." + ) + reporter.warning( + "settings_model", + "last_played_book references an non existent object. Setting last_played_book to None.", + ) self.last_played_book = None return None @last_played_book.setter - def last_played_book(self, new_value): + def last_played_book(self, new_value) -> None: if new_value: self._db_object.last_played_book = new_value._db_object else: @@ -47,11 +51,8 @@ def last_played_book(self, new_value): self._db_object.save(only=self._db_object.dirty_fields) @property - def default_location(self) -> bool: - for location in self.storage_locations: - if location.default: - return True - return False + def default_location(self) -> Storage: + return next(location for location in self.storage_locations if location.default) @property def storage_locations(self) -> list[Storage]: @@ -67,11 +68,11 @@ def external_storage_locations(self) -> list[Storage]: return [storage for storage in self._storages if storage.external] - def invalidate(self): - self._storages = [] + def invalidate(self) -> None: + self._storages.clear() - def _load_all_storage_locations(self): - self._storages = [] + def _load_all_storage_locations(self) -> None: + self.invalidate() for storage_db_obj in StorageModel.select(StorageModel.id): try: @@ -79,9 +80,9 @@ def _load_all_storage_locations(self): except InvalidPath: log.error("Invalid path found in database, skipping: %s", storage_db_obj.path) - self._ensure_default_storage_present() + self._ensure_default_storage_is_present() - def _ensure_default_storage_present(self): + def _ensure_default_storage_is_present(self): default_storage_present = any(storage.default for storage in self._storages) if not default_storage_present and self._storages: diff --git a/cozy/model/storage.py b/cozy/model/storage.py index cd5a8fc9..b09a3309 100644 --- a/cozy/model/storage.py +++ b/cozy/model/storage.py @@ -1,4 +1,4 @@ -import os +from pathlib import Path from peewee import SqliteDatabase @@ -17,8 +17,8 @@ def __init__(self, db: SqliteDatabase, db_id: int): self._get_db_object() @staticmethod - def new(db: SqliteDatabase): - db_obj = StorageModel.create(path="") + def new(db: SqliteDatabase, path: str): + db_obj = StorageModel.create(path=path) return Storage(db, db_obj.id) def _get_db_object(self): @@ -33,11 +33,11 @@ def path(self): return self._db_object.path @path.setter - def path(self, new_path: str): - if not os.path.isabs(new_path): + def path(self, path: str): + if not Path(path).is_absolute(): raise InvalidPath - self._db_object.path = new_path + self._db_object.path = path self._db_object.save(only=self._db_object.dirty_fields) @property @@ -68,4 +68,4 @@ def external(self, new_external: bool): self._db_object.save(only=self._db_object.dirty_fields) def delete(self): - self._db_object.delete_instance(recursive=True, delete_nullable=False) \ No newline at end of file + self._db_object.delete_instance(recursive=True, delete_nullable=False) diff --git a/cozy/ui/main_view.py b/cozy/ui/main_view.py index e66d93ea..63460131 100644 --- a/cozy/ui/main_view.py +++ b/cozy/ui/main_view.py @@ -17,6 +17,8 @@ from cozy.media.importer import Importer, ScanStatus from cozy.media.player import Player from cozy.model.settings import Settings as SettingsModel +from cozy.view_model.storages_view_model import StoragesViewModel +from cozy.open_view import OpenView from cozy.ui.library_view import LibraryView from cozy.ui.preferences_view import PreferencesView from cozy.view_model.settings_view_model import SettingsViewModel @@ -37,7 +39,7 @@ class CozyUI(EventSender, metaclass=Singleton): _settings: SettingsModel = inject.attr(SettingsModel) _files: Files = inject.attr(Files) _player: Player = inject.attr(Player) - _settings_view_model: SettingsViewModel = inject.attr(SettingsViewModel) + _storages_view_model: StoragesViewModel = inject.attr(StoragesViewModel) def __init__(self, pkgdatadir, app, version): super().__init__() @@ -243,9 +245,9 @@ def switch_to_playing(self): self.block_ui_buttons(False, True) else: # we want to only block the player controls + # TODO: rework. this is messy self.block_ui_buttons(False, True) self.block_ui_buttons(True, False) - self.emit_event_main_thread("working", False) def check_for_tracks(self): """ @@ -303,7 +305,7 @@ def _on_drag_data_received(self, widget, value, *_): return True def _set_audiobook_path(self, path): - self._settings_view_model.add_first_storage_location(path) + self._storages_view_model.add_first_storage_location(path) self.main_stack.props.visible_child_name = "import" self.scan(None, None) self.fs_monitor.init_offline_mode() diff --git a/cozy/ui/preferences_view.py b/cozy/ui/preferences_view.py index 3fa2ca0b..decca8ff 100644 --- a/cozy/ui/preferences_view.py +++ b/cozy/ui/preferences_view.py @@ -1,158 +1,95 @@ +from typing import Any from gi.repository import Adw, Gio, Gtk from cozy.ext import inject from cozy.ui.widgets.error_reporting import ErrorReporting -from cozy.ui.widgets.storage_list_box_row import StorageListBoxRow + +from cozy.ui.widgets.storages import StorageLocations from cozy.view_model.settings_view_model import SettingsViewModel -@Gtk.Template.from_resource('/com/github/geigi/cozy/preferences.ui') +@Gtk.Template.from_resource("/com/github/geigi/cozy/preferences.ui") class PreferencesView(Adw.PreferencesWindow): __gtype_name__ = "PreferencesWindow" - main_window = inject.attr("MainWindow") - _glib_settings: Gio.Settings = inject.attr(Gio.Settings) _view_model: SettingsViewModel = inject.attr(SettingsViewModel) - dark_mode_switch: Gtk.Switch = Gtk.Template.Child() - swap_author_reader_switch: Gtk.Switch = Gtk.Template.Child() - replay_switch: Gtk.Switch = Gtk.Template.Child() + storages_page: Adw.PreferencesPage = Gtk.Template.Child() + user_feedback_preference_group: Adw.PreferencesGroup = Gtk.Template.Child() + + dark_mode_switch: Adw.SwitchRow = Gtk.Template.Child() + swap_author_reader_switch: Adw.SwitchRow = Gtk.Template.Child() + replay_switch: Adw.SwitchRow = Gtk.Template.Child() sleep_timer_fadeout_switch: Adw.SwitchRow = Gtk.Template.Child() - fadeout_duration_spin_button: Adw.SpinRow = Gtk.Template.Child() - artwork_prefer_external_switch: Gtk.Switch = Gtk.Template.Child() + artwork_prefer_external_switch: Adw.SwitchRow = Gtk.Template.Child() rewind_duration_adjustment: Gtk.Adjustment = Gtk.Template.Child() forward_duration_adjustment: Gtk.Adjustment = Gtk.Template.Child() fadeout_duration_adjustment: Gtk.Adjustment = Gtk.Template.Child() - storage_list_box: Gtk.ListBox = Gtk.Template.Child() - add_storage_button: Gtk.Button = Gtk.Template.Child() - remove_storage_button: Gtk.Button = Gtk.Template.Child() - external_storage_toggle_button: Gtk.ToggleButton = Gtk.Template.Child() - default_storage_button: Gtk.ToggleButton = Gtk.Template.Child() - - user_feedback_preference_group: Adw.PreferencesRow = Gtk.Template.Child() - - def __init__(self, **kwargs): - super().__init__(**kwargs) + def __init__(self, **kwargs: Any) -> None: + main_window = inject.instance("MainWindow") + super().__init__(transient_for=main_window.window, **kwargs) error_reporting = ErrorReporting() error_reporting.show_header(False) self.user_feedback_preference_group.add(error_reporting) + self.storage_locations_view = StorageLocations() + self.storages_page.add(self.storage_locations_view) + + self._view_model.bind_to("lock_ui", self._on_lock_ui_changed) self._bind_settings() - self._bind_view_model() - - self.sleep_timer_fadeout_switch.connect("notify::active", self._on_sleep_fadeout_switch_changed) - self.fadeout_duration_spin_button.set_sensitive(self.sleep_timer_fadeout_switch.props.active) - - self.storage_list_box.connect("row-selected", self._on_storage_box_changed) - - self.add_storage_button.connect("clicked", self._on_add_storage_clicked) - self.remove_storage_button.connect("clicked", self._on_remove_storage_clicked) - self.external_button_handle_id = self.external_storage_toggle_button.connect("clicked", self._on_external_clicked) - self.default_storage_button.connect("clicked", self._on_default_storage_clicked) - - self.set_transient_for(self.main_window.window) - - self._init_storage_box() - - def _bind_view_model(self): - self._view_model.bind_to("storage_locations", self._init_storage_box) - self._view_model.bind_to("storage_attributes", self._refresh_storage_rows) - - def _bind_settings(self): - self._glib_settings.bind("dark-mode", self.dark_mode_switch, "active", - Gio.SettingsBindFlags.DEFAULT) - - self._glib_settings.bind("swap-author-reader", self.swap_author_reader_switch, "active", - Gio.SettingsBindFlags.DEFAULT) - - self._glib_settings.bind("replay", self.replay_switch, "active", Gio.SettingsBindFlags.DEFAULT) - self._glib_settings.bind("rewind-duration", self.rewind_duration_adjustment, "value", - Gio.SettingsBindFlags.DEFAULT) - self._glib_settings.bind("forward-duration", self.forward_duration_adjustment, "value", - Gio.SettingsBindFlags.DEFAULT) - - self._glib_settings.bind("sleep-timer-fadeout", self.sleep_timer_fadeout_switch, "active", - Gio.SettingsBindFlags.DEFAULT) - - self._glib_settings.bind("sleep-timer-fadeout-duration", self.fadeout_duration_adjustment, - "value", Gio.SettingsBindFlags.DEFAULT) - - self._glib_settings.bind("prefer-external-cover", self.artwork_prefer_external_switch, "active", - Gio.SettingsBindFlags.DEFAULT) - - def _on_sleep_fadeout_switch_changed(self, widget, param): - state = widget.get_property(param.name) - self.fadeout_duration_spin_button.set_sensitive(state) - - def _init_storage_box(self): - self.storage_list_box.remove_all_children() - - for storage in self._view_model.storage_locations: - row = StorageListBoxRow(storage) - row.connect("location-changed", self._on_storage_location_changed) - self.storage_list_box.append(row) - - def _on_add_storage_clicked(self, _): - self._view_model.add_storage_location() - - def _on_remove_storage_clicked(self, _): - row = self.storage_list_box.get_selected_row() - self._view_model.remove_storage_location(row.model) - - def _on_default_storage_clicked(self, _): - row = self.storage_list_box.get_selected_row() - self._view_model.set_default_storage(row.model) - self._on_storage_box_changed(None, row) - - def _on_storage_box_changed(self, _, row): - row = self.storage_list_box.get_selected_row() - if row is None: - sensitive = False - default_sensitive = False - remove_sensitive = False - else: - sensitive = True - remove_sensitive = True - if row.model.default or not row.model.path: - default_sensitive = remove_sensitive = False - else: - default_sensitive = True - - if not row.model.path: - remove_sensitive = True - - self.external_storage_toggle_button.handler_block(self.external_button_handle_id) - self.external_storage_toggle_button.set_active(row.model.external) - self.external_storage_toggle_button.handler_unblock(self.external_button_handle_id) - - self.remove_storage_button.set_sensitive(remove_sensitive) - self.external_storage_toggle_button.set_sensitive(sensitive) - self.default_storage_button.set_sensitive(default_sensitive) - - def _on_external_clicked(self, _): - external = self.external_storage_toggle_button.get_active() - row = self.storage_list_box.get_selected_row() - self._view_model.set_storage_external(row.model, external) - - def _on_storage_location_changed(self, widget, new_location): - self._view_model.change_storage_location(widget.model, new_location) - - def _refresh_storage_rows(self): - self._init_storage_box() - - self._on_storage_box_changed(None, self.storage_list_box.get_selected_row()) - - def _on_lock_ui_changed(self): - sensitive = not self._view_model.lock_ui - - self.storage_list_box.set_sensitive(sensitive) - self.add_storage_button.set_sensitive(sensitive) - self.remove_storage_button.set_sensitive(sensitive) - self.external_storage_toggle_button.set_sensitive(sensitive) - self.default_storage_button.set_sensitive(sensitive) - self._on_storage_box_changed(None, self.storage_list_box.get_selected_row()) + def _bind_settings(self) -> None: + self._glib_settings.bind( + "dark-mode", self.dark_mode_switch, "active", Gio.SettingsBindFlags.DEFAULT + ) + + self._glib_settings.bind( + "swap-author-reader", + self.swap_author_reader_switch, + "active", + Gio.SettingsBindFlags.DEFAULT, + ) + + self._glib_settings.bind( + "replay", self.replay_switch, "active", Gio.SettingsBindFlags.DEFAULT + ) + self._glib_settings.bind( + "rewind-duration", + self.rewind_duration_adjustment, + "value", + Gio.SettingsBindFlags.DEFAULT, + ) + self._glib_settings.bind( + "forward-duration", + self.forward_duration_adjustment, + "value", + Gio.SettingsBindFlags.DEFAULT, + ) + + self._glib_settings.bind( + "sleep-timer-fadeout", + self.sleep_timer_fadeout_switch, + "enable-expansion", + Gio.SettingsBindFlags.DEFAULT, + ) + + self._glib_settings.bind( + "sleep-timer-fadeout-duration", + self.fadeout_duration_adjustment, + "value", + Gio.SettingsBindFlags.DEFAULT, + ) + + self._glib_settings.bind( + "prefer-external-cover", + self.artwork_prefer_external_switch, + "active", + Gio.SettingsBindFlags.DEFAULT, + ) + + def _on_lock_ui_changed(self) -> None: + self.storage_locations_view.set_sensitive(not self._view_model.lock_ui) diff --git a/cozy/ui/widgets/storage_list_box_row.py b/cozy/ui/widgets/storage_list_box_row.py deleted file mode 100644 index fcf97bd9..00000000 --- a/cozy/ui/widgets/storage_list_box_row.py +++ /dev/null @@ -1,100 +0,0 @@ -import logging -from threading import Thread - -from cozy.control.filesystem_monitor import FilesystemMonitor -from cozy.model.storage import Storage -from cozy.ext import inject -from cozy.model.library import Library -from cozy.model.settings import Settings -from gi.repository import Gtk, GObject, Gio, GLib - -log = logging.getLogger("settings") - - -class StorageListBoxRow(Gtk.ListBoxRow): - """ - This class represents a listboxitem for a storage location. - """ - - main_window = inject.attr("MainWindow") - - def __init__(self, model: Storage): - self._model = model - - super(Gtk.ListBoxRow, self).__init__() - box = Gtk.Box() - box.set_orientation(Gtk.Orientation.HORIZONTAL) - box.set_spacing(3) - box.set_halign(Gtk.Align.FILL) - box.set_valign(Gtk.Align.CENTER) - box.set_margin_start(6) - box.set_margin_end(6) - box.set_margin_top(12) - box.set_margin_bottom(12) - - self.default_image = Gtk.Image() - self.default_image.set_from_icon_name("emblem-default-symbolic") - self.default_image.set_margin_end(5) - - self.type_image = Gtk.Image() - self._set_drive_icon() - self.location_chooser = Gtk.Button() - self.location_label = Gtk.Label() - self.location_chooser.set_child(self.location_label) - self.location_chooser.set_margin_end(6) - self.location_chooser.connect("clicked", self._on_location_chooser_clicked) - - self.location_label.set_text(model.path) - - box.append(self.type_image) - box.append(self.location_chooser) - box.append(self.default_image) - self.set_child(box) - self._set_default_icon() - - @property - def model(self) -> Storage: - return self._model - - def refresh(self): - self._set_drive_icon() - self._set_default_icon() - - def __on_folder_changed(self, new_path): - self.emit("location-changed", new_path) - - def _set_drive_icon(self): - if self._model.external: - icon_name = "network-server-symbolic" - self.type_image.set_tooltip_text(_("External drive")) - else: - icon_name = "drive-harddisk-symbolic" - self.type_image.set_tooltip_text(_("Internal drive")) - - self.type_image.set_from_icon_name(icon_name) - self.type_image.set_margin_end(5) - - def _set_default_icon(self): - self.default_image.set_visible(self._model.default) - - def _on_location_chooser_clicked(self, *junk): - location_chooser = Gtk.FileDialog(title=_("Set Audiobooks Directory")) - - if self._model.path != "": - folder = Gio.File.new_for_path(self._model.path) - location_chooser.set_initial_folder(folder) - - location_chooser.select_folder(self.main_window.window, None, self._location_chooser_open_callback) - - def _location_chooser_open_callback(self, dialog, result): - try: - file = dialog.select_folder_finish(result) - except GLib.GError: - pass - else: - if file is not None: - self.__on_folder_changed(file.get_path()) - -GObject.signal_new('location-changed', StorageListBoxRow, GObject.SIGNAL_RUN_LAST, GObject.TYPE_PYOBJECT, - (GObject.TYPE_PYOBJECT,)) - diff --git a/cozy/ui/widgets/storages.py b/cozy/ui/widgets/storages.py new file mode 100644 index 00000000..2cf21ab4 --- /dev/null +++ b/cozy/ui/widgets/storages.py @@ -0,0 +1,168 @@ +from typing import Callable + +from gi.repository import Adw, Gio, GLib, GObject, Gtk + +from cozy.ext import inject +from cozy.model.storage import Storage +from cozy.view_model.storages_view_model import StoragesViewModel + + +def ask_storage_location(callback: Callable[[str], None], initial_folder: str | None = None): + location_chooser = Gtk.FileDialog(title=_("Set Audiobooks Directory")) + + if initial_folder: + gfile = Gio.File.new_for_path(initial_folder) + location_chooser.set_initial_folder(gfile) + + def finish_callback(dialog, result): + try: + file = dialog.select_folder_finish(result) + except GLib.GError: + pass + else: + if file is not None: + callback(file.get_path()) + + location_chooser.select_folder(inject.instance("MainWindow").window, None, finish_callback) + + +@Gtk.Template.from_resource("/com/github/geigi/cozy/storage_row.ui") +class StorageRow(Adw.ActionRow): + __gtype_name__ = "StorageRow" + + icon: Gtk.Image = Gtk.Template.Child() + default_icon: Gtk.Image = Gtk.Template.Child() + menu_button: Gtk.MenuButton = Gtk.Template.Child() + + def __init__(self, model: Storage, menu_model: Gio.Menu) -> None: + self._model = model + + super().__init__(title=model.path) + self.connect("activated", self.ask_for_new_location) + + self.menu_button.set_menu_model(menu_model) + self.menu_button.connect("notify::active", self._on_menu_opened) + + self._set_default_icon() + self._set_drive_icon() + + @property + def model(self) -> Storage: + return self._model + + def ask_for_new_location(self, *_) -> None: + ask_storage_location(self._on_folder_changed, initial_folder=self._model.path) + + def _on_folder_changed(self, new_path: str) -> None: + self.emit("location-changed", new_path) + + def _on_menu_opened(self, *_) -> None: + self.emit("menu-opened") + + def _set_drive_icon(self) -> None: + if self._model.external: + self.icon.set_from_icon_name("network-server-symbolic") + self.icon.set_tooltip_text(_("External drive")) + else: + self.icon.set_from_icon_name("folder-open-symbolic") + self.icon.set_tooltip_text(_("Internal drive")) + + def _set_default_icon(self) -> None: + self.default_icon.set_visible(self._model.default) + + +GObject.signal_new( + "location-changed", + StorageRow, + GObject.SIGNAL_RUN_LAST, + GObject.TYPE_PYOBJECT, + (GObject.TYPE_PYOBJECT,), +) +GObject.signal_new("menu-opened", StorageRow, GObject.SIGNAL_RUN_LAST, GObject.TYPE_PYOBJECT, ()) + + +@Gtk.Template.from_resource("/com/github/geigi/cozy/storage_locations.ui") +class StorageLocations(Adw.PreferencesGroup): + __gtype_name__ = "StorageLocations" + + _view_model: StoragesViewModel = inject.attr(StoragesViewModel) + + storage_locations_list: Gtk.ListBox = Gtk.Template.Child() + storage_menu: Gio.Menu = Gtk.Template.Child() + + def __init__(self) -> None: + super().__init__() + + self._view_model.bind_to("storage_locations", self._reload_storage_list) + self._view_model.bind_to("storage_attributes", self._reload_storage_list) + + self._create_actions() + self.new_storage_button = self._create_new_storage_button() + + self._reload_storage_list() + + def _create_actions(self) -> None: + self.action_group = Gio.SimpleActionGroup.new() + self.insert_action_group("storage", self.action_group) + + self.set_external_action = Gio.SimpleAction.new_stateful( + "mark-external", None, GLib.Variant.new_boolean(False) + ) + self._set_external_signal_handler = self.set_external_action.connect( + "notify::state", self._mark_storage_location_external + ) + self.action_group.add_action(self.set_external_action) + + self.remove_action = Gio.SimpleAction.new("remove", None) + self.remove_action.connect("activate", self._remove_storage_location) + self.action_group.add_action(self.remove_action) + + self.make_default_action = Gio.SimpleAction.new("make-default", None) + self.make_default_action.connect("activate", self._set_default_storage_location) + self.action_group.add_action(self.make_default_action) + + def _create_new_storage_button(self) -> Adw.ActionRow: + icon = Gtk.Image(icon_name="list-add-symbolic", margin_top=18, margin_bottom=18) + row = Adw.ActionRow(selectable=False, activatable=True) + row.connect("activated", self._on_new_storage_clicked) + row.set_child(icon) + return row + + def _reload_storage_list(self) -> None: + self.storage_locations_list.remove_all() + + for storage in self._view_model.storages: + row = StorageRow(storage, menu_model=self.storage_menu) + row.connect("location-changed", self._on_storage_location_changed) + row.connect("menu-opened", self._on_storage_menu_opened) + self.storage_locations_list.append(row) + + self.storage_locations_list.append(self.new_storage_button) + + def _remove_storage_location(self, *_) -> None: + self._view_model.remove(self._view_model.selected_storage) + + def _set_default_storage_location(self, *_) -> None: + self._view_model.set_default(self._view_model.selected_storage) + + def _mark_storage_location_external( + self, action: Gio.SimpleAction, value: GObject.ParamSpec + ) -> None: + value = action.get_property(value.name) + self._view_model.set_external(self._view_model.selected_storage, value) + + def _on_new_storage_clicked(self, *_) -> None: + ask_storage_location(self._view_model.add_storage_location) + + def _on_storage_location_changed(self, widget: StorageRow, new_location: str) -> None: + self._view_model.change_storage_location(widget.model, new_location) + + def _on_storage_menu_opened(self, widget: StorageRow) -> None: + with self.set_external_action.handler_block(self._set_external_signal_handler): + self.set_external_action.props.state = GLib.Variant.new_boolean(widget.model.external) + + self.remove_action.props.enabled = ( + not widget.model.default and len(self._view_model.storages) > 1 + ) + self.make_default_action.props.enabled = widget.model is not self._view_model.default + self._view_model.selected_storage = widget.model diff --git a/cozy/view_model/headerbar_view_model.py b/cozy/view_model/headerbar_view_model.py index f2ec365c..d160220a 100644 --- a/cozy/view_model/headerbar_view_model.py +++ b/cozy/view_model/headerbar_view_model.py @@ -63,10 +63,12 @@ def _start_working(self, message: str): self._notify("work_message") self._notify("work_progress") self._notify("state") + self.emit_event_main_thread("working", True) def _stop_working(self): self._state = HeaderBarState.PLAYING self._notify("state") + self.emit_event_main_thread("working", False) def _on_importer_event(self, event: str, message): if event == "scan-progress" and isinstance(message, float): diff --git a/cozy/view_model/library_view_model.py b/cozy/view_model/library_view_model.py index 180ac467..2639ca8e 100644 --- a/cozy/view_model/library_view_model.py +++ b/cozy/view_model/library_view_model.py @@ -18,7 +18,7 @@ from cozy.report import reporter from cozy.ui.widgets.book_element import BookElement from cozy.ui.import_failed_dialog import ImportFailedDialog -from cozy.view_model.settings_view_model import SettingsViewModel +from cozy.view_model.storages_view_model import StoragesViewModel log = logging.getLogger("library_view_model") @@ -35,7 +35,7 @@ class LibraryViewModel(Observable, EventSender): _model = inject.attr(Library) _importer: Importer = inject.attr(Importer) _player: Player = inject.attr(Player) - _settings: SettingsViewModel = inject.attr(SettingsViewModel) + _storages: StoragesViewModel = inject.attr(StoragesViewModel) def __init__(self): super().__init__() @@ -52,7 +52,7 @@ def _connect(self): self._importer.add_listener(self._on_importer_event) self._player.add_listener(self._on_player_event) self._model.add_listener(self._on_model_event) - self._settings.add_listener(self._on_settings_event) + self._storages.add_listener(self._on_storages_event) @property def books(self): @@ -91,8 +91,7 @@ def authors(self): authors = { book.author - for book - in self._model.books + for book in self._model.books if is_book_online(book) or show_offline_books or book.downloaded } @@ -105,8 +104,7 @@ def readers(self): readers = { book.reader - for book - in self._model.books + for book in self._model.books if is_book_online(book) or show_offline_books or book.downloaded } @@ -209,24 +207,17 @@ def _on_player_event(self, event, message): elif event in {"position", "book-finished"}: self._notify("book-progress") - def _on_settings_event(self, event: str, message): + def _on_storages_event(self, event: str, message): if event == "storage-removed": - self._on_external_storage_removed(message) - - def _on_external_storage_removed(self, storage: Storage): - books = self.books.copy() - for book in books: - chapters_to_remove = [c for c in book.chapters if c.file.startswith(str(storage.path))] - - for chapter in chapters_to_remove: - chapter.delete() - - self._notify("authors") - self._notify("readers") - self._notify("books") - self._notify("books-filter") - self._notify("current_book_in_playback") - self._notify("playing") + for property in ( + "authors", + "readers", + "books", + "books-filter", + "current_book_in_playback", + "playing", + ): + self._notify(property) def _on_model_event(self, event: str, message): if event == "rebase-finished": diff --git a/cozy/view_model/settings_view_model.py b/cozy/view_model/settings_view_model.py index 716aa524..f5333529 100644 --- a/cozy/view_model/settings_view_model.py +++ b/cozy/view_model/settings_view_model.py @@ -1,31 +1,21 @@ import logging -from threading import Thread -from typing import List -from peewee import SqliteDatabase +from gi.repository import Adw, Gtk + from cozy.application_settings import ApplicationSettings from cozy.architecture.event_sender import EventSender from cozy.architecture.observable import Observable -from cozy.control.filesystem_monitor import FilesystemMonitor -from cozy.model.library import Library -from cozy.model.storage import Storage from cozy.ext import inject from cozy.media.importer import Importer from cozy.model.settings import Settings -from cozy.report import reporter -from gi.repository import Gtk, Adw - - log = logging.getLogger("settings_view_model") + class SettingsViewModel(Observable, EventSender): - _library: Library = inject.attr(Library) _importer: Importer = inject.attr(Importer) _model: Settings = inject.attr(Settings) _app_settings: ApplicationSettings = inject.attr(ApplicationSettings) - _db = inject.attr(SqliteDatabase) - _fs_monitor = inject.attr(FilesystemMonitor) def __init__(self): super().__init__() @@ -33,7 +23,6 @@ def __init__(self): self._lock_ui: bool = False - self._gtk_settings = Gtk.Settings.get_default() self.style_manager = Adw.StyleManager.get_default() self._set_dark_mode() @@ -42,84 +31,15 @@ def __init__(self): if self._model.first_start: self._importer.scan() - @property - def storage_locations(self) -> List[Storage]: - return self._model.storage_locations - @property def lock_ui(self) -> bool: return self._lock_ui - + @lock_ui.setter def lock_ui(self, new_value: bool): self._lock_ui = new_value self._notify("lock_ui") - def add_storage_location(self): - Storage.new(self._db) - self._model.invalidate() - self._notify("storage_locations") - - def remove_storage_location(self, model: Storage): - if model.default: - log.error("deleting the default storage location %s is not possible", model.path) - reporter.error("settings_view_model", "deleting the default storage location is not possible") - return - - model.delete() - self._model.invalidate() - self._notify("storage_locations") - self.emit_event("storage-removed", model) - - def set_storage_external(self, model: Storage, external: bool): - model.external = external - - if external: - self.emit_event("external-storage-added", model) - else: - self.emit_event("external-storage-removed", model) - - self._notify("storage_attributes") - - def set_default_storage(self, model: Storage): - if model.default: - return - - for storage in self._model.storage_locations: - storage.default = False - - model.default = True - - self._notify("storage_attributes") - - def change_storage_location(self, model: Storage, new_path: str): - old_path = model.path - model.path = new_path - model.external = self._fs_monitor.is_external(new_path) - - if old_path == "": - self.emit_event("storage-added", model) - log.info("New audiobook location added. Starting import scan.") - thread = Thread(target=self._importer.scan, name="ImportThread") - thread.start() - else: - self.emit_event("storage-changed", model) - log.info("Audio book location changed, rebasing the location in Cozy.") - thread = Thread(target=self._library.rebase_path, args=(old_path, new_path), name="RebaseStorageLocationThread") - thread.start() - - self._notify("storage_attributes") - - def add_first_storage_location(self, path: str): - storage = self._model.storage_locations[0] - - storage.path = path - storage.default = True - storage.external = self._fs_monitor.is_external(path) - - self._model.invalidate() - self._notify("storage_locations") - def _set_dark_mode(self): if self._app_settings.dark_mode: self.style_manager.set_color_scheme(Adw.ColorScheme.PREFER_DARK) diff --git a/cozy/view_model/storages_view_model.py b/cozy/view_model/storages_view_model.py new file mode 100644 index 00000000..9316c012 --- /dev/null +++ b/cozy/view_model/storages_view_model.py @@ -0,0 +1,129 @@ +import logging +from threading import Thread + +from peewee import SqliteDatabase + +from cozy.application_settings import ApplicationSettings +from cozy.architecture.event_sender import EventSender +from cozy.architecture.observable import Observable +from cozy.control.filesystem_monitor import FilesystemMonitor +from cozy.ext import inject +from cozy.media.importer import Importer +from cozy.model.library import Library +from cozy.model.settings import Settings +from cozy.model.storage import Storage + +log = logging.getLogger("storages_view_model") + + +class StoragesViewModel(Observable, EventSender): + _library: Library = inject.attr(Library) + _importer: Importer = inject.attr(Importer) + _model: Settings = inject.attr(Settings) + _app_settings: ApplicationSettings = inject.attr(ApplicationSettings) + _db = inject.attr(SqliteDatabase) + _fs_monitor = inject.attr(FilesystemMonitor) + + def __init__(self) -> None: + super().__init__() + super(Observable, self).__init__() + + self._selected_storage = None + + def _scan_new_storage(self, model: Storage) -> None: + self.emit_event("storage-added", model) + log.info("New audiobook location added. Starting import scan.") + Thread(target=self._importer.scan, name="ImportThread").start() + + def _rebase_storage_location(self, model: Storage, old_path: str) -> None: + self.emit_event("storage-changed", model) + log.info("Audiobook location changed, rebasing the location in Cozy.") + Thread( + target=self._library.rebase_path, + args=(old_path, model.path), + name="RebaseStorageLocationThread", + ).start() + + def add_storage_location(self, path: str) -> None: + model = Storage.new(self._db, path) + model.external = self._fs_monitor.is_external(path) + + self._model.invalidate() + self._notify("storage_locations") + + self._scan_new_storage(model) + + def add_first_storage_location(self, path: str) -> None: + storage = self.storages[0] + storage.path = path + storage.external = self._fs_monitor.is_external(path) + assert storage.default + + self._model.invalidate() + self._notify("storage_locations") + + def change_storage_location(self, model: Storage, new_path: str) -> None: + old_path = model.path + model.path = new_path + model.external = self._fs_monitor.is_external(new_path) + + self._rebase_storage_location(model, old_path) + self._notify("storage_attributes") + + @property + def storages(self) -> list[Storage]: + return self._model.storage_locations + + @property + def default(self) -> Storage | None: + for item in self.storages: + if item.default: + return item + + @property + def selected_storage(self) -> Storage | None: + return self._selected_storage + + @selected_storage.setter + def selected_storage(self, value) -> None: + self._selected_storage = value + + def remove(self, model: Storage) -> None: + if model.default: + return + + model.delete() + self._model.invalidate() + + storage_path = str(model.path) + for book in self._library.books: + chapters_to_remove = [ + c for c in book.chapters if c.file.startswith(storage_path) + ] + + for chapter in chapters_to_remove: + chapter.delete() + + self.emit_event("storage-removed", model) + self._notify("storage_locations") + + def set_default(self, model: Storage) -> None: + if model.default: + return + + for storage in self.storages: + storage.default = False + + model.default = True + + self._notify("storage_attributes") + + def set_external(self, model: Storage, external: bool) -> None: + model.external = external + + if external: + self.emit_event("external-storage-added", model) + else: + self.emit_event("external-storage-removed", model) + + self._notify("storage_attributes") diff --git a/data/ui/gresource.xml b/data/ui/gresource.xml index 5049a867..8d9ea192 100644 --- a/data/ui/gresource.xml +++ b/data/ui/gresource.xml @@ -15,6 +15,8 @@ progress_popover.ui search_page.ui seek_bar.ui + storage_locations.ui + storage_row.ui timer_popover.ui welcome.ui whats_new.ui diff --git a/data/ui/preferences.ui b/data/ui/preferences.ui index 3df26ebf..a38b3219 100644 --- a/data/ui/preferences.ui +++ b/data/ui/preferences.ui @@ -79,17 +79,19 @@ Sleep Timer - + Fadeout - - - - - Fadeout duration - true - fadeout_duration_adjustment - true - true + true + true + + + Fadeout duration + true + fadeout_duration_adjustment + true + true + + @@ -97,7 +99,7 @@ - + harddisk-symbolic Storage @@ -111,134 +113,6 @@ - - - Storage locations - - - Storage locations - - - 13 - 13 - 13 - 13 - true - vertical - - - 250 - - - true - never - - - - - 1 - - - - - - - - - - - - - - 24 - - - - 24 - 24 - Add location - - - list-add-symbolic - - - - - - - - 24 - 24 - false - Remove location - - - list-remove-symbolic - - - - - - - - vertical - True - - - - - - External drive - 24 - 24 - false - Toggle this storage location to be internal/external. - - - network-server-symbolic - - - - - - - - Set as default - 24 - 24 - false - Set as default storage location for new audiobooks - - - check-plain-symbolic - - - - - - - - - - - - - - diff --git a/data/ui/storage_locations.ui b/data/ui/storage_locations.ui new file mode 100644 index 00000000..afb6dce7 --- /dev/null +++ b/data/ui/storage_locations.ui @@ -0,0 +1,34 @@ + + + + + +
+ + External drive + storage.mark-external + +
+
+ + Set as default + storage.make-default + + + Remove + storage.remove + +
+
+
+ diff --git a/data/ui/storage_row.ui b/data/ui/storage_row.ui new file mode 100644 index 00000000..0669bcf9 --- /dev/null +++ b/data/ui/storage_row.ui @@ -0,0 +1,32 @@ + + + + + diff --git a/test/cozy/model/test_settings.py b/test/cozy/model/test_settings.py index 6bba3875..8cf0639b 100644 --- a/test/cozy/model/test_settings.py +++ b/test/cozy/model/test_settings.py @@ -86,7 +86,7 @@ def test_fetching_non_existent_last_played_book_sets_it_to_none(peewee_database) assert SettingsModel.get().last_played_book is None -def test_ensure_default_storage_present_adds_default_if_not_present(peewee_database): +def test_ensure_default_storage_is_present_adds_default_if_not_present(peewee_database): from cozy.model.settings import Settings from cozy.db.storage import Storage @@ -94,17 +94,17 @@ def test_ensure_default_storage_present_adds_default_if_not_present(peewee_datab settings = Settings() settings._load_all_storage_locations() - settings._ensure_default_storage_present() + settings._ensure_default_storage_is_present() assert Storage.get(1).default assert not Storage.get(2).default -def test_ensure_default_storage_present_does_nothing_if_default_is_present(peewee_database): +def test_ensure_default_storage_is_present_does_nothing_if_default_is_present(peewee_database): from cozy.model.settings import Settings from cozy.db.storage import Storage settings = Settings() settings._load_all_storage_locations() - settings._ensure_default_storage_present() + settings._ensure_default_storage_is_present() assert not Storage.get(1).default assert Storage.get(2).default