diff --git a/README.md b/README.md index f202df48..9da89caa 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,6 @@ See [DEVELOPMENT.md](DEVELOPMENT.md) for detailed instructions and developing Co - `distro` - `requests` - `pytz` -- `packaging` - `gi-cairo` - `gst-1.0` - `file` diff --git a/com.github.geigi.cozy.json b/com.github.geigi.cozy.json index 922f7e6b..70013767 100644 --- a/com.github.geigi.cozy.json +++ b/com.github.geigi.cozy.json @@ -49,20 +49,6 @@ } ] }, - { - "name": "python3-packaging", - "buildsystem": "simple", - "build-commands": [ - "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"packaging\" --no-build-isolation" - ], - "sources": [ - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/ec/1a/610693ac4ee14fcdf2d9bf3c493370e4f2ef7ae2e19217d7a237ff42367d/packaging-23.2-py3-none-any.whl", - "sha256": "8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7" - } - ] - }, { "name": "python3-peewee", "buildsystem": "simple", diff --git a/cozy/app_controller.py b/cozy/app_controller.py index ec1fbd53..28ff2ab4 100644 --- a/cozy/app_controller.py +++ b/cozy/app_controller.py @@ -26,7 +26,6 @@ from cozy.ui.main_view import CozyUI from cozy.ui.media_controller import MediaController from cozy.ui.search_view import SearchView -from cozy.ui.widgets.whats_new_window import WhatsNewWindow from cozy.view import View from cozy.view_model.app_view_model import AppViewModel from cozy.view_model.book_detail_view_model import BookDetailViewModel @@ -37,6 +36,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): @@ -49,8 +49,6 @@ def __init__(self, gtk_app, main_window_builder, main_window): reporter.info("main", "startup") - self.whats_new_window: WhatsNewWindow = WhatsNewWindow() - self.library_view: LibraryView = LibraryView(main_window_builder) self.app_view: AppView = AppView(main_window_builder) self.search_view: SearchView = SearchView() @@ -75,7 +73,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 +106,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 @@ -146,10 +145,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/application.py b/cozy/application.py index 33521fda..fee6d702 100644 --- a/cozy/application.py +++ b/cozy/application.py @@ -1,5 +1,3 @@ -import gettext -import locale import logging import os import platform @@ -9,59 +7,26 @@ from traceback import format_exception import distro - -import gi - -from cozy.db.storage import Storage -from cozy.ui.widgets.filter_list_box import FilterListBox -from cozy.ui.widgets.seek_bar import SeekBar - -from gi.repository import Gtk, GLib, Adw +from gi.repository import Adw, GLib from cozy.app_controller import AppController from cozy.control.db import init_db from cozy.control.mpris import MPRIS from cozy.db.settings import Settings +from cozy.db.storage import Storage from cozy.report import reporter from cozy.ui.main_view import CozyUI +from cozy.ui.widgets.filter_list_box import FilterListBox from cozy.version import __version__ - log = logging.getLogger("application") -def setup_thread_excepthook(): - """ - Workaround for `sys.excepthook` thread bug from: - http://bugs.python.org/issue1230540 - - Call once from the main thread before creating any threads. - """ - - init_original = threading.Thread.__init__ - - def init(self, *args, **kwargs): - - init_original(self, *args, **kwargs) - run_original = self.run - - def run_with_except_hook(*args2, **kwargs2): - try: - run_original(*args2, **kwargs2) - except Exception: - sys.excepthook(*sys.exc_info()) - - self.run = run_with_except_hook - - threading.Thread.__init__ = init - - class Application(Adw.Application): ui: CozyUI app_controller: AppController - def __init__(self, localedir: str, pkgdatadir: str): - self.localedir = localedir + def __init__(self, pkgdatadir: str): self.pkgdatadir = pkgdatadir super().__init__(application_id='com.github.geigi.cozy') @@ -70,19 +35,7 @@ def __init__(self, localedir: str, pkgdatadir: str): GLib.setenv("PULSE_PROP_media.role", "music", True) GLib.set_application_name("Cozy") - self.old_except_hook = sys.excepthook - sys.excepthook = self.handle_exception - setup_thread_excepthook() - - # We need to call `locale.*textdomain` to get the strings in UI files translated - locale.bindtextdomain('com.github.geigi.cozy', localedir) - locale.textdomain('com.github.geigi.cozy') - - # But also `gettext.*textdomain`, to make `_("foo")` in Python work as well - gettext.bindtextdomain('com.github.geigi.cozy', localedir) - gettext.textdomain('com.github.geigi.cozy') - - gettext.install('com.github.geigi.cozy', localedir) + threading.excepthook = self.handle_exception def do_startup(self): log.info(distro.linux_distribution(full_distribution_name=False)) @@ -114,14 +67,18 @@ def do_activate(self): mpris = MPRIS(self) mpris._on_current_changed() - def handle_exception(self, exc_type, exc_value, exc_traceback): + def handle_exception(self, _): print("handle exception") + + exc_type, exc_value, exc_traceback = sys.exc_info() + + if exc_type is SystemExit: + return + try: reporter.exception("uncaught", exc_value, "\n".join(format_exception(exc_type, exc_value, exc_traceback))) - except Exception: - None - - self.old_except_hook(exc_type, exc_value, exc_traceback) + finally: + sys.excepthook(exc_type, exc_value, exc_traceback) def quit(self): self.app_controller.quit() diff --git a/cozy/control/mpris.py b/cozy/control/mpris.py index 00bd2d88..42e0746d 100644 --- a/cozy/control/mpris.py +++ b/cozy/control/mpris.py @@ -5,67 +5,95 @@ # copyright (c) 2013 Arnel A. Borja # copyright (c) 2013 Vadim Rutkovsky # copyright (c) 2017 Julian Geywitz -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . +# copyright (c) 2023 Benedek Dévényi + import logging -from gi.repository import Gio, GLib, Gtk +import re +import time +from dataclasses import dataclass -from random import randint +from gi.repository import Gio, GLib -import cozy.ui from cozy.application_settings import ApplicationSettings from cozy.control.artwork_cache import ArtworkCache from cozy.ext import inject -from cozy.media.player import Player +from cozy.media.player import NS_TO_SEC, US_TO_SEC, Player from cozy.model.book import Book from cozy.report import reporter -log = logging.getLogger("offline_cache") +log = logging.getLogger("mpris") + +CamelCasePattern = re.compile(r"(? str: + return CamelCasePattern.sub("_", name).lower() + + +@dataclass(kw_only=True, frozen=True, slots=True) +class Metadata: + track_id: str + track_number: int + title: str + album: str + artist: list[str] + length: int + url: str + artwork_uri: str + + def to_dict(self) -> dict[str, GLib.Variant]: + data = {} + data["mpris:trackid"] = GLib.Variant("o", self.track_id) + data["xesam:trackNumber"] = GLib.Variant("i", self.track_number) + data["xesam:title"] = GLib.Variant("s", self.title) + data["xesam:album"] = GLib.Variant("s", self.album) + data["xesam:artist"] = GLib.Variant("as", self.artist) + data["mpris:length"] = GLib.Variant("x", self.length) + data["xesam:url"] = GLib.Variant("s", self.url) + if self.artwork_uri: + data["mpris:artUrl"] = GLib.Variant("s", "file://" + self.artwork_uri) -class UnsupportedProperty(Exception): - pass + return data + + @staticmethod + def no_track() -> dict[str, GLib.Variant]: + no_track_path = GLib.Variant("o", "/org/mpris/MediaPlayer2/TrackList/NoTrack") + return {"mpris:trackid": no_track_path} class Server: - def __init__(self, con, path): - method_outargs = {} - method_inargs = {} - for interface in Gio.DBusNodeInfo.new_for_xml(self.__doc__).interfaces: + def __init__(self, connection: Gio.DBusConnection, path: str) -> None: + self.method_outargs = {} + self.method_inargs = {} + for interface in Gio.DBusNodeInfo.new_for_xml(self.__doc__).interfaces: for method in interface.methods: - method_outargs[method.name] = "(" + "".join( - [arg.signature for arg in method.out_args]) + ")" - method_inargs[method.name] = tuple( - arg.signature for arg in method.in_args) + self.method_inargs[method.name] = tuple( + arg.signature for arg in method.in_args + ) + out_sig = [arg.signature for arg in method.out_args] + self.method_outargs[method.name] = "(" + "".join(out_sig) + ")" try: - con.register_object(object_path=path, - interface_info=interface, - method_call_closure=self.on_method_call) - except: - log.error("MPRIS is already connected from another cozy process.") - - self.method_inargs = method_inargs - self.method_outargs = method_outargs - - def on_method_call(self, - connection, - sender, - object_path, - interface_name, - method_name, - parameters, - invocation): - + connection.register_object( + object_path=path, + interface_info=interface, + method_call_closure=self.on_method_call, + ) + except Exception: + log.error("MPRIS is already connected from another Cozy process.") + + def on_method_call( + self, + connection: Gio.DBusConnection, + sender: str, + object_path: str, + interface_name: str, + method_name: str, + parameters: GLib.Variant, + invocation: Gio.DBusMethodInvocation, + ) -> None: args = list(parameters.unpack()) for i, sig in enumerate(self.method_inargs[method_name]): if sig == "h": @@ -73,350 +101,311 @@ def on_method_call(self, fd_list = msg.get_unix_fd_list() args[i] = fd_list.get(args[i]) - out_args = None + snake_method = to_snake_case(method_name) try: - result = getattr(self, method_name)(*args) - - # out_args is atleast (signature1). + result = getattr(self, snake_method)(*args) + except AttributeError: + invocation.return_dbus_error( + "{}.Error.NotSupported".format(interface_name), "Unsupported property" + ) + except Exception as e: + log.error(e) + reporter.exception("mpris", e) + reporter.error( + "mpris", + "MPRIS method call failed with method name: {}".format(method_name), + ) + invocation.return_dbus_error( + "{}.Error.Failed".format(interface_name), "Internal exception occurred" + ) + else: + # out_args is at least (signature1). # We therefore always wrap the result as a tuple. - # Refer to https://bugzilla.gnome.org/show_bug.cgi?id=765603 + # Reference: + # https://bugzilla.gnome.org/show_bug.cgi?id=765603 result = (result,) out_args = self.method_outargs[method_name] - if out_args and out_args != "()" and result[0]: + if out_args != "()" and result[0]: variant = GLib.Variant(out_args, result) invocation.return_value(variant) else: invocation.return_value(None) - except UnsupportedProperty: - invocation.return_dbus_error("{}.Error.NotSupported".format(interface_name), "Unsupported property") - except Exception as e: - log.error(e) - reporter.exception("mpris", e) - reporter.error("mpris", "MPRIS method call failed with method name: {}".format(method_name)) - if out_args: - reporter.error("mpris", "MPRIS method call failed with out_args: {}".format(out_args)) - invocation.return_dbus_error("{}.Error.Failed".format(interface_name), "Internal exception occurred") class MPRIS(Server): """ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """ - __MPRIS_IFACE = "org.mpris.MediaPlayer2" - __MPRIS_PLAYER_IFACE = "org.mpris.MediaPlayer2.Player" - __MPRIS_RATINGS_IFACE = "org.mpris.MediaPlayer2.ExtensionSetRatings" - __MPRIS_COZY = "org.mpris.MediaPlayer2.Cozy" - __MPRIS_PATH = "/org/mpris/MediaPlayer2" + + MEDIA_PLAYER2_INTERFACE = "org.mpris.MediaPlayer2" + MEDIA_PLAYER2_PLAYER_INTERFACE = "org.mpris.MediaPlayer2.Player" + _player: Player = inject.attr(Player) _artwork_cache: ArtworkCache = inject.attr(ArtworkCache) _app_settings: ApplicationSettings = inject.attr(ApplicationSettings) - def __init__(self, app): - self.__app = app - self.__ui = cozy.ui.main_view.CozyUI() - self.__rating = None - self.__cozy_id = 0 - self.__metadata = {"mpris:trackid": GLib.Variant( - "o", - "/org/mpris/MediaPlayer2/TrackList/NoTrack")} - self.__track_id = self.__get_media_id(0) - self.__bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) - Gio.bus_own_name_on_connection(self.__bus, - self.__MPRIS_COZY, - Gio.BusNameOwnerFlags.NONE, - None, - None) - Server.__init__(self, self.__bus, self.__MPRIS_PATH) + def __init__(self, app) -> None: + self._bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) + Gio.bus_own_name_on_connection( + self._bus, + "org.mpris.MediaPlayer2.Cozy", + Gio.BusNameOwnerFlags.NONE, + None, + None, + ) + super().__init__(self._bus, "/org/mpris/MediaPlayer2") + + self._application = app + self._metadata = self._get_new_metadata() self._player.add_listener(self._on_player_changed) self._app_settings.add_listener(self._on_app_setting_changed) - def Raise(self): - try: - self.__app.ui.window.present_with_time(Gtk.get_current_event_time()) - except Exception as e: - reporter.exception("mpris", e) + def introspect(self): + return self.__doc__ - def Quit(self): - self.__app.quit() + def quit(self): + self._application.quit() - def Next(self): + def next(self): self._player.forward() - def Previous(self): + def previous(self): self._player.rewind() - def Pause(self): - self._player.pause() - - def PlayPause(self): + def play(self): self._player.play_pause() - def Stop(self): - self._player.destroy() + def pause(self): + self._player.pause() - def Play(self): + def play_pause(self): self._player.play_pause() - def SetPosition(self, track_id, position): - self._player.position = position * 10**3 + def stop(self): + self._player.destroy() - def Seek(self, offset): - self._player.position = self._player.position + offset * 10**3 + def set_position(self, track_id: str, position: int): + self._player.position = position / US_TO_SEC - def Seeked(self, position): - self.__bus.emit_signal( - None, - self.__MPRIS_PATH, - "org.freedesktop.DBus.Properties", - "Seeked", - GLib.Variant.new_tuple(GLib.Variant("x", position))) + def seek(self, offset: int): + self._player.position = self._player.position / NS_TO_SEC + offset / US_TO_SEC - def Get(self, interface, property_name): - if property_name in ["CanQuit", "CanRaise", "CanSeek", - "CanControl", "HasRatingsExtension"]: + def get(self, interface: str, property_name: str) -> GLib.Variant: + if property_name in {"CanQuit", "CanControl"}: return GLib.Variant("b", True) - elif property_name == "HasTrackList": + elif property_name in {"CanRaise", "HasTrackList"}: return GLib.Variant("b", False) - elif property_name == "Identity": - return GLib.Variant("s", "Cozy") - elif property_name == "DesktopEntry": - return GLib.Variant("s", "com.github.geigi.cozy") - elif property_name == "SupportedUriSchemes": - return GLib.Variant("as", ["file"]) - elif property_name == "SupportedMimeTypes": - return GLib.Variant("as", ["application/ogg", - "audio/x-vorbis+ogg", - "audio/x-flac", - "audio/mpeg"]) - elif property_name == "PlaybackStatus": - return GLib.Variant("s", self.__get_status()) - elif property_name == "Metadata": - return GLib.Variant("a{sv}", self.__metadata) - elif property_name == "Position": - return GLib.Variant( - "x", - round(self._player.position * 10**-3)) - elif property_name in ["CanGoNext", "CanGoPrevious", - "CanPlay", "CanPause"]: + elif property_name in { + "CanGoNext", + "CanGoPrevious", + "CanPlay", + "CanPause", + "CanSeek", + }: return GLib.Variant("b", self._player.loaded_book is not None) - elif property_name == "Volume": - return GLib.Variant("d", self._player.volume) + elif property_name in {"SupportedUriSchemes", "SupportedMimeTypes"}: + return GLib.Variant("as", []) + + # Might raise an AttributeError. We handle that in Server.on_method_call + return getattr(self, to_snake_case(property_name)) + + def get_all(self, interface): + if interface == self.MEDIA_PLAYER2_INTERFACE: + properties = ( + "CanQuit", + "CanRaise", + "HasTrackList", + "Identity", + "DesktopEntry", + "SupportedUriSchemes", + "SupportedMimeTypes", + ) + elif interface == self.MEDIA_PLAYER2_PLAYER_INTERFACE: + properties = ( + "PlaybackStatus", + "Metadata", + "Position", + "CanGoNext", + "CanGoPrevious", + "CanPlay", + "CanPause", + "CanSeek", + "CanControl", + "Volume", + ) + + return {property: self.get(interface, property) for property in properties} + + def properties_changed(self, iface_name, changed_props, invalidated_props): + self._bus.emit_signal( + None, + "/org/mpris/MediaPlayer2", + "org.freedesktop.DBus.Properties", + "PropertiesChanged", + GLib.Variant.new_tuple( + GLib.Variant("s", iface_name), + GLib.Variant("a{sv}", changed_props), + GLib.Variant("as", invalidated_props), + ), + ) + + @property + def desktop_entry(self): + return GLib.Variant("s", "com.github.geigi.cozy") + + @property + def identity(self): + return GLib.Variant("s", "Cozy") + + @property + def playback_status(self): + if self._player.playing: + return GLib.Variant("s", "Playing") + elif not self._player.loaded_book: + return GLib.Variant("s", "Stopped") else: - reporter.warning("mpris", "MPRIS required an unknown information: {}".format(property_name)) - raise UnsupportedProperty - - def GetAll(self, interface): - ret = {} - if interface == self.__MPRIS_IFACE: - for property_name in ["CanQuit", - "CanRaise", - "HasTrackList", - "Identity", - "DesktopEntry", - "SupportedUriSchemes", - "SupportedMimeTypes"]: - ret[property_name] = self.Get(interface, property_name) - elif interface == self.__MPRIS_PLAYER_IFACE: - for property_name in ["PlaybackStatus", - "Metadata", - "Position", - "CanGoNext", - "CanGoPrevious", - "CanPlay", - "CanPause", - "CanSeek", - "CanControl"]: - ret[property_name] = self.Get(interface, property_name) - elif interface == self.__MPRIS_RATINGS_IFACE: - ret["HasRatingsExtension"] = GLib.Variant("b", False) - return ret - - def Set(self, interface, property_name, new_value): - if property_name == "Volume": - self._player.volume = new_value - - def PropertiesChanged(self, interface_name, changed_properties, - invalidated_properties): - self.__bus.emit_signal(None, - self.__MPRIS_PATH, - "org.freedesktop.DBus.Properties", - "PropertiesChanged", - GLib.Variant.new_tuple( - GLib.Variant("s", interface_name), - GLib.Variant("a{sv}", changed_properties), - GLib.Variant("as", invalidated_properties))) - - def Introspect(self): - return self.__doc__ + return GLib.Variant("s", "Paused") + + @property + def metadata(self): + return GLib.Variant("a{sv}", self._metadata) - ####################### - # PRIVATE # - ####################### + @property + def position(self): + return GLib.Variant("x", round(self._player.position / 1e3)) - def __get_media_id(self, track_id): + @property + def volume(self): + return GLib.Variant("d", self._player.volume) + + def _get_track_id(self) -> float: """ - TrackId's must be unique even up to - the point that if you repeat a song - it must have a different TrackId. + Track IDs must be unique even up to the point that if a song + is repeated in a playlist it must have a different TrackId. """ - track_id = track_id + randint(10000000, 90000000) - return GLib.Variant("o", "/com/github/geigi/cozy/TrackId/%s" % track_id) + return time.time() * 1e10 % 1e10 - def __get_status(self): - if self._player.playing: - return "Playing" - elif not self._player.loaded_book: - return "Stopped" - else: - return "Paused" - - def _on_player_changed(self, event, message): + def _get_new_metadata(self, book: Book | None = None) -> dict[str, GLib.Variant]: + if book is None: + return Metadata.no_track() + + track_path_template = "/com/github/geigi/cozy/TrackId/{id:.0f}" + uri_template = "file://{path}" + + metadata = Metadata( + track_id=track_path_template.format(id=self._get_track_id()), + track_number=book.current_chapter.number, + title=book.current_chapter.name, + album=book.name, + artist=[book.author], + length=book.current_chapter.length * US_TO_SEC, + url=uri_template.format(path=book.current_chapter.file), + artwork_uri=self._artwork_cache.get_album_art_path(book, 256), + ) + return metadata.to_dict() + + def _on_player_changed(self, event: str, _) -> None: if event == "chapter-changed": self._on_current_changed() elif event == "play": - self.__on_status_changed("Playing") + self._on_status_changed("Playing") elif event == "pause": - self.__on_status_changed("Paused") + self._on_status_changed("Paused") elif event == "stop": - self.__on_status_changed("Stopped") + self._on_status_changed("Stopped") - def _on_app_setting_changed(self, event, _): + def _on_app_setting_changed(self, event: str, _): if event == "swap-author-reader": self._on_current_changed() - def __update_metadata(self, book: Book): - # if track is None: - # track = get_current_track() - if book is None: - self.__metadata = {"mpris:trackid": GLib.Variant( - "o", - "/org/mpris/MediaPlayer2/TrackList/NoTrack")} - else: - self.__metadata["mpris:trackid"] = self.__track_id - track_number = book.current_chapter.number - - self.__metadata["xesam:trackNumber"] = GLib.Variant("i", - track_number) - self.__metadata["xesam:title"] = GLib.Variant( - "s", - book.current_chapter.name) - self.__metadata["xesam:album"] = GLib.Variant( - "s", - book.name) - self.__metadata["xesam:artist"] = GLib.Variant( - "as", - [book.author]) - self.__metadata["mpris:length"] = GLib.Variant( - "x", - book.current_chapter.length * 1000 * 1000) - self.__metadata["xesam:url"] = GLib.Variant( - "s", - "file:///" + book.current_chapter.file) - - path = self._artwork_cache.get_album_art_path(book, 180) - if path: - self.__metadata["mpris:artUrl"] = GLib.Variant( - "s", - "file://" + path) - - def __on_seeked(self, player, position): - self.Seeked(position * (1000 * 1000)) - - def _on_current_changed(self): + def _on_current_changed(self) -> None: if not self._player.loaded_book: return - current_track_id = self._player.loaded_chapter.id - if current_track_id and current_track_id >= 0: - self.__cozy_id = current_track_id - else: - self.__cozy_id = 0 - self.__track_id = self.__get_media_id(self.__cozy_id) - self.__rating = None - self.__update_metadata(self._player.loaded_book) - properties = {"Metadata": GLib.Variant("a{sv}", self.__metadata), - "CanPlay": GLib.Variant("b", True), - "CanPause": GLib.Variant("b", True), - "CanGoNext": GLib.Variant("b", True), - "CanGoPrevious": GLib.Variant("b", True)} - try: - self.PropertiesChanged(self.__MPRIS_PLAYER_IFACE, properties, []) - except Exception as e: - print("MPRIS::__on_current_changed(): %s" % e) + self._metadata = self._get_new_metadata(self._player.loaded_book) + + properties = { + "Metadata": GLib.Variant("a{sv}", self._metadata), + "CanPlay": GLib.Variant("b", True), + "CanPause": GLib.Variant("b", True), + "CanGoNext": GLib.Variant("b", True), + "CanGoPrevious": GLib.Variant("b", True), + } + + self.properties_changed(self.MEDIA_PLAYER2_PLAYER_INTERFACE, properties, []) - def __on_status_changed(self, status, data=None): + def _on_status_changed(self, status: str) -> None: properties = {"PlaybackStatus": GLib.Variant("s", status)} - self.PropertiesChanged(self.__MPRIS_PLAYER_IFACE, properties, []) + self.properties_changed(self.MEDIA_PLAYER2_PLAYER_INTERFACE, properties, []) diff --git a/cozy/media/player.py b/cozy/media/player.py index 7a12122e..e018a4b0 100644 --- a/cozy/media/player.py +++ b/cozy/media/player.py @@ -22,6 +22,7 @@ log = logging.getLogger("mediaplayer") +US_TO_SEC = 10 ** 6 NS_TO_SEC = 10 ** 9 REWIND_SECONDS = 30 @@ -80,7 +81,9 @@ def position(self) -> int: @position.setter def position(self, new_value: int): - self._gst_player.position = self.loaded_chapter.start_position + (new_value * NS_TO_SEC) + # FIXME: setter expects seconds, but getter returns nanoseconds + if self.loaded_chapter is not None: + self._gst_player.position = max(self.loaded_chapter.start_position + (new_value * NS_TO_SEC), 0) @property def volume(self) -> float: @@ -172,8 +175,7 @@ def forward(self): def destroy(self): self._gst_player.dispose() - - self._stop_tick_thread() + self._stop_playback() if self._fadeout_thread: self._fadeout_thread.stop() diff --git a/cozy/model/settings.py b/cozy/model/settings.py index 432415bb..8afa96dc 100644 --- a/cozy/model/settings.py +++ b/cozy/model/settings.py @@ -1,5 +1,4 @@ import logging -from typing import List, Optional import peewee @@ -16,7 +15,7 @@ class Settings: - _storages: List[Storage] = [] + _storages: list[Storage] = [] _db = cache = inject.attr(SqliteDatabase) def __init__(self): @@ -27,19 +26,23 @@ def first_start(self) -> bool: return self._db_object.first_start @property - def last_played_book(self) -> Optional[Book]: + 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: @@ -48,44 +51,39 @@ def last_played_book(self, new_value): self._db_object.save(only=self._db_object.dirty_fields) @property - def default_location(self): - return next(location - for location - in self.storage_locations - if location.default) + def default_location(self) -> Storage: + return next(location for location in self.storage_locations if location.default) @property - def storage_locations(self): + def storage_locations(self) -> list[Storage]: if not self._storages: self._load_all_storage_locations() return self._storages @property - def external_storage_locations(self): + def external_storage_locations(self) -> list[Storage]: if not self._storages: self._load_all_storage_locations() 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: self._storages.append(Storage(self._db, storage_db_obj.id)) except InvalidPath: - log.error("Invalid path found in database, skipping: {}".format(storage_db_obj.path)) + log.error( + "Invalid path found in database, skipping: {}".format(storage_db_obj.path) + ) - self._ensure_default_storage_present() + self._ensure_default_storage_is_present() - def _ensure_default_storage_present(self): - default_storage_present = any(storage.default - for storage - in self._storages) - - if not default_storage_present and len(self._storages) > 0: + def _ensure_default_storage_is_present(self) -> None: + if self._storages and not any(storage.default for storage in self._storages): self._storages[0].default = True 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/app_view.py b/cozy/ui/app_view.py index cc16e548..09d5ca89 100644 --- a/cozy/ui/app_view.py +++ b/cozy/ui/app_view.py @@ -5,7 +5,7 @@ from cozy.view import View LIBRARY = "main" -EMPTY_STATE = "no_media" +EMPTY_STATE = "welcome" PREPARING_LIBRARY = "import" BOOK_DETAIL = "book_overview" diff --git a/cozy/ui/library_view.py b/cozy/ui/library_view.py index 3e530919..ee779ada 100644 --- a/cozy/ui/library_view.py +++ b/cozy/ui/library_view.py @@ -14,7 +14,7 @@ AUTHOR_PAGE = "author" RECENT_PAGE = "recent" MAIN_BOOK_PAGE = "main" -NO_MEDIA_PAGE = "no_media" +WELCOME_PAGE = "welcome" NO_RECENT_PAGE = "no_recent" BOOKS_PAGE = "books" @@ -113,7 +113,7 @@ def _on_library_view_mode_changed(self): books_view_page = BOOKS_PAGE if len(self._view_model.books) < 1: - main_view_page = NO_MEDIA_PAGE + main_view_page = WELCOME_PAGE visible_child_name = RECENT_PAGE elif view_mode == LibraryViewMode.CURRENT: visible_child_name = RECENT_PAGE diff --git a/cozy/ui/main_view.py b/cozy/ui/main_view.py index e0b2e20a..53b13223 100644 --- a/cozy/ui/main_view.py +++ b/cozy/ui/main_view.py @@ -17,10 +17,11 @@ 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.settings_view_model import SettingsViewModel +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.ui.widgets.first_import_button import FirstImportButton log = logging.getLogger("ui") @@ -38,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__() @@ -109,9 +110,6 @@ def __init_window(self): self.navigation_view: Adw.NavigationView = self.window_builder.get_object("navigation_view") self.drop_revealer: Gtk.Revealer = self.window_builder.get_object("drop_revealer") - self.no_media_file_chooser = self.window_builder.get_object("no_media_file_chooser") - self.no_media_file_chooser.connect("clicked", self._open_audiobook_dir_selector) - self.about_dialog = self.about_builder.get_object("about_dialog") self.about_dialog.set_modal(self.window) self.about_dialog.connect("close-request", self.hide_window) @@ -164,6 +162,10 @@ def __init_actions(self): self.app.add_action(self.hide_offline_action) def __init_components(self): + path = self._settings.default_location.path if self._settings.storage_locations else None + import_button = FirstImportButton(self._set_audiobook_path, path) + self.get_object("welcome_status_page").set_child(import_button) + if not self._player.loaded_book: self.block_ui_buttons(True) @@ -243,15 +245,15 @@ def switch_to_playing(self): Switch the UI state back to playing. This enables all UI functionality for the user. """ - if self.navigation_view.props.visible_page != "book_overview" and self.main_stack.props.visible_child_name != "no_media": + if self.navigation_view.props.visible_page != "book_overview" and self.main_stack.props.visible_child_name != "welcome": self.navigation_view.pop_to_page("main") if self._player.loaded_book: 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): """ @@ -261,23 +263,6 @@ def check_for_tracks(self): if books().count() < 1: self.block_ui_buttons(True) - def _open_audiobook_dir_selector(self, __): - path = "" - if len(self._settings.storage_locations) > 0: - path = self._settings.default_location.path - - location_chooser = Gtk.FileDialog(title=_("Set Audiobooks Directory")) - location_chooser.select_folder(self.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._set_audiobook_path(file.get_path()) - def scan(self, _, __): thread = Thread(target=self._importer.scan, name="ScanMediaThread") thread.start() @@ -309,8 +294,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.main_stack.props.visible_child_name = "import" + self._storages_view_model.add_first_storage_location(path) 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 709488a1..6fd02e57 100644 --- a/cozy/ui/preferences_view.py +++ b/cozy/ui/preferences_view.py @@ -1,14 +1,14 @@ -from gi.repository import Gtk -from cozy.view_model.settings_view_model import SettingsViewModel -import gi -from gi.repository import Adw, Gio +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" @@ -17,149 +17,89 @@ class PreferencesView(Adw.PreferencesWindow): _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: + super().__init__(transient_for=self.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._bind_settings() - self._bind_view_model() + + self._view_model.bind_to("lock_ui", self._on_lock_ui_changed) self.connect("close-request", self._hide_window) - 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): + 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: 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()) - + self.storage_locations_view.set_sensitive(sensitive) + def _hide_window(self, *_): self.hide() return True diff --git a/cozy/ui/widgets/first_import_button.py b/cozy/ui/widgets/first_import_button.py new file mode 100644 index 00000000..bfeabffd --- /dev/null +++ b/cozy/ui/widgets/first_import_button.py @@ -0,0 +1,28 @@ +from gi.repository import Adw, Gtk, GObject + +from .storages import ask_storage_location + +from typing import Callable + + +@Gtk.Template.from_resource('/com/github/geigi/cozy/first_import_button.ui') +class FirstImportButton(Gtk.Button): + __gtype_name__ = "FirstImportButton" + + stack: Gtk.Stack = Gtk.Template.Child() + label: Adw.ButtonContent = Gtk.Template.Child() + spinner: Gtk.Spinner = Gtk.Template.Child() + + def __init__(self, callback: Callable[[str], None], initial_folder: str) -> None: + super().__init__() + + self._callback = callback + self._initial_folder = initial_folder + + self.connect("clicked", self._on_clicked) + + def _on_clicked(self, *_) -> None: + ask_storage_location(self._callback, self._initial_folder) + self.set_sensitive(False) + self.spinner.set_spinning(True) + self.stack.set_visible_child(self.spinner) 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/ui/widgets/welcome.py b/cozy/ui/widgets/welcome.py deleted file mode 100644 index c2537653..00000000 --- a/cozy/ui/widgets/welcome.py +++ /dev/null @@ -1,11 +0,0 @@ -import gi - -from gi.repository import Adw, Gtk - - -@Gtk.Template.from_resource('/com/github/geigi/cozy/welcome.ui') -class Welcome(Adw.Bin): - __gtype_name__ = "Welcome" - - def __init__(self, **kwargs): - super().__init__(**kwargs) diff --git a/cozy/ui/widgets/whats_new_importer.py b/cozy/ui/widgets/whats_new_importer.py deleted file mode 100644 index 95113fd2..00000000 --- a/cozy/ui/widgets/whats_new_importer.py +++ /dev/null @@ -1,9 +0,0 @@ -from gi.repository import Gtk - - -@Gtk.Template.from_resource('/com/github/geigi/cozy/whats_new_importer.ui') -class WhatsNewImporter(Gtk.Box): - __gtype_name__ = "WhatsNewImporter" - - def __init__(self, **kwargs): - super().__init__(**kwargs) diff --git a/cozy/ui/widgets/whats_new_library.py b/cozy/ui/widgets/whats_new_library.py deleted file mode 100644 index abea0bdd..00000000 --- a/cozy/ui/widgets/whats_new_library.py +++ /dev/null @@ -1,11 +0,0 @@ -from gi.repository import Gtk - - -INTRODUCED = "0.9" - -@Gtk.Template.from_resource('/com/github/geigi/cozy/whats_new_library.ui') -class WhatsNewLibrary(Gtk.Box): - __gtype_name__ = "WhatsNewLibrary" - - def __init__(self, **kwargs): - super().__init__(**kwargs) diff --git a/cozy/ui/widgets/whats_new_m4b.py b/cozy/ui/widgets/whats_new_m4b.py deleted file mode 100644 index 50f4e1ee..00000000 --- a/cozy/ui/widgets/whats_new_m4b.py +++ /dev/null @@ -1,12 +0,0 @@ -from gi.repository import Gtk - - -INTRODUCED = "0.7.2" - - -@Gtk.Template.from_resource('/com/github/geigi/cozy/whats_new_m4b.ui') -class WhatsNewM4B(Gtk.Box): - __gtype_name__ = "WhatsNewM4B" - - def __init__(self, **kwargs): - super().__init__(**kwargs) diff --git a/cozy/ui/widgets/whats_new_m4b_chapter.py b/cozy/ui/widgets/whats_new_m4b_chapter.py deleted file mode 100644 index d0a33acf..00000000 --- a/cozy/ui/widgets/whats_new_m4b_chapter.py +++ /dev/null @@ -1,12 +0,0 @@ -from gi.repository import Gtk - - -INTRODUCED = "1.0.0" - - -@Gtk.Template.from_resource('/com/github/geigi/cozy/whats_new_m4b_chapter.ui') -class WhatsNewM4BChapter(Gtk.Box): - __gtype_name__ = "WhatsNewM4BChapter" - - def __init__(self, **kwargs): - super().__init__(**kwargs) diff --git a/cozy/ui/widgets/whats_new_window.py b/cozy/ui/widgets/whats_new_window.py deleted file mode 100644 index 0850609f..00000000 --- a/cozy/ui/widgets/whats_new_window.py +++ /dev/null @@ -1,93 +0,0 @@ -from typing import List -from packaging import version - -from cozy.application_settings import ApplicationSettings -from cozy.ext import inject -from cozy.ui.main_view import CozyUI -from cozy.version import __version__ as CozyVersion - -from gi.repository import Gtk, Adw - - -@Gtk.Template(resource_path='/com/github/geigi/cozy/whats_new.ui') -class WhatsNewWindow(Adw.Window): - __gtype_name__ = 'WhatsNew' - - content_stack: Gtk.Stack = Gtk.Template.Child() - continue_button: Gtk.Button = Gtk.Template.Child() - children: List[Gtk.Widget] - - main_window: CozyUI = inject.attr("MainWindow") - app_settings: ApplicationSettings = inject.attr(ApplicationSettings) - - page = 0 - - def __init__(self, **kwargs): - if self.app_settings.last_launched_version == CozyVersion: - return - - super().__init__(**kwargs) - - self.set_modal(self.main_window.window) - - self._fill_window() - if len(self.children) < 1: - self.end() - return - - self.set_default_size(800, 550) - - for widget in self.children: - self.content_stack.add_child(widget) - widget.set_visible(False) - - self.children[0].set_visible(True) - self.continue_button.connect("clicked", self.__on_continue_clicked) - self.show() - - def _fill_window(self): - self.children = [] - - last_launched_version = version.parse(self.app_settings.last_launched_version) - - if type(last_launched_version) is version.LegacyVersion: - self._fill_welcome() - else: - self._fill_whats_new(last_launched_version) - - def _fill_welcome(self): - from cozy.ui.widgets.welcome import Welcome - from cozy.ui.widgets.error_reporting import ErrorReporting - self.children.append(Welcome()) - self.children.append(ErrorReporting()) - - def _fill_whats_new(self, last_launched_version: version.Version): - from cozy.ui.widgets.whats_new_m4b_chapter import INTRODUCED - if last_launched_version < version.parse(INTRODUCED): - from cozy.ui.widgets.whats_new_m4b_chapter import WhatsNewM4BChapter - self.children.append(WhatsNewM4BChapter()) - - from cozy.ui.widgets.whats_new_library import INTRODUCED - if last_launched_version < version.parse(INTRODUCED): - from cozy.ui.widgets.whats_new_library import WhatsNewLibrary - self.children.append(WhatsNewLibrary()) - - from cozy.ui.widgets.whats_new_m4b import INTRODUCED - if last_launched_version < version.parse(INTRODUCED): - from cozy.ui.widgets.whats_new_m4b import WhatsNewM4B - self.children.append(WhatsNewM4B()) - - def __on_continue_clicked(self, widget): - if len(self.children) == self.page + 1: - self.end() - return - - self.children[self.page].set_visible(False) - self.page += 1 - self.content_stack.set_visible_child(self.children[self.page]) - self.children[self.page].set_visible(True) - - def end(self): - self.app_settings.last_launched_version = CozyVersion - self.close() - self.destroy() diff --git a/cozy/view_model/headerbar_view_model.py b/cozy/view_model/headerbar_view_model.py index dc20d622..9b5e2223 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/settings_view_model.py b/cozy/view_model/settings_view_model.py index 28251f8a..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 {} is not possible".format(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..c564d6f4 --- /dev/null +++ b/cozy/view_model/storages_view_model.py @@ -0,0 +1,120 @@ +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() + 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/first_import_button.ui b/data/ui/first_import_button.ui new file mode 100644 index 00000000..ff26a8b3 --- /dev/null +++ b/data/ui/first_import_button.ui @@ -0,0 +1,26 @@ + + + + + diff --git a/data/ui/gresource.xml b/data/ui/gresource.xml index 05828d20..ad42a176 100644 --- a/data/ui/gresource.xml +++ b/data/ui/gresource.xml @@ -8,6 +8,7 @@ book_element.ui chapter_element.ui error_reporting.ui + first_import_button.ui headerbar.ui main_window.ui media_controller.ui @@ -16,12 +17,8 @@ progress_popover.ui search_popover.ui seek_bar.ui + storage_locations.ui + storage_row.ui timer_popover.ui - welcome.ui - whats_new.ui - whats_new_importer.ui - whats_new_library.ui - whats_new_m4b.ui - whats_new_m4b_chapter.ui diff --git a/data/ui/main_window.ui b/data/ui/main_window.ui index b6ad6048..4a7c3f15 100644 --- a/data/ui/main_window.ui +++ b/data/ui/main_window.ui @@ -261,7 +261,7 @@ - no_media + welcome @@ -270,24 +270,10 @@ - - com.github.geigi.cozy - Let's get cozy - Select a folder, or drag audiobooks here to import them - - - center - - - Select folder - - - - - + + com.github.geigi.cozy + Let's get cozy + Select a folder, or drag audiobooks here to add them to your library diff --git a/data/ui/preferences.ui b/data/ui/preferences.ui index 20045d83..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 - - - checkmark-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/data/ui/style.css b/data/ui/style.css index 89fbd3fc..f5635405 100644 --- a/data/ui/style.css +++ b/data/ui/style.css @@ -1,54 +1,27 @@ -.white { - color: white; -} - .box_hover { - color: @theme_selected_fg_color; - background: @theme_selected_bg_color; -} - -.selected { - color: @theme_selected_fg_color; + color: @theme_selected_fg_color; + background: @theme_selected_bg_color; } .no_frame { - border-style: none; -} - -.sort_switcher_element { - font-size: 110%; -} - -.sort_switcher_element > .radio { - min-width: 100px; -} - -.no_padding { - padding: 0px; + border-style: none; } .unavailable_box { - background-color: #C01C28; - border-radius: 25px; - padding: 3px; - padding-right: 10px; -} - -.unavailable_image { - color: white; -} - -.unavailable_label { - color: white; + background-color: @red_4; + border-radius: 25px; + padding: 3px; + padding-right: 10px; + color: white; } .book_card { - padding: 1rem; + padding: 1rem; } .selected { - color: @theme_selected_fg_color; - background-color: @theme_selected_bg_color; + color: @theme_selected_fg_color; + background-color: @theme_selected_bg_color; } .book_detail_art { @@ -56,35 +29,35 @@ } .chapter_element { - border-radius: 0.5rem; + border-radius: 0.5rem; } .book_play_button { - background: rgba(50, 50, 50, 1.0); - border: none; - color: white; - box-shadow: none; - transition: none; - -gtk-icon-shadow: none; + background: rgba(50, 50, 50, 1.0); + border: none; + color: white; + box-shadow: none; + transition: none; + -gtk-icon-shadow: none; } .book_play_button:hover, .book_play_button:active, .play_button:hover, .play_button:active { - border: none; - box-shadow: none; - -gtk-icon-shadow: none; + border: none; + box-shadow: none; + -gtk-icon-shadow: none; } .filter-list-box-row { - border-radius: 0.5rem; + border-radius: 0.5rem; } .play_button { - background-color: @theme_fg_color; - color: @theme_bg_color; + background-color: @theme_fg_color; + color: @theme_bg_color; } .player_bar { - background: shade(@theme_bg_color, 0.95) 0%; + background: shade(@theme_bg_color, 0.95) 0%; } .bold { @@ -95,16 +68,8 @@ font-weight: 600; } -.bordered { - border: 1px solid shade(@theme_bg_color, 0.8); -} - -.monospace { - font-family: monospace; -} - .transparent_bg { - background: transparent; + background: transparent; } .failed-import-card { diff --git a/data/ui/welcome.ui b/data/ui/welcome.ui deleted file mode 100644 index 532cde76..00000000 --- a/data/ui/welcome.ui +++ /dev/null @@ -1,13 +0,0 @@ - - - - - diff --git a/data/ui/whats_new.ui b/data/ui/whats_new.ui deleted file mode 100644 index dda8ca65..00000000 --- a/data/ui/whats_new.ui +++ /dev/null @@ -1,52 +0,0 @@ - - - - - diff --git a/data/ui/whats_new_importer.ui b/data/ui/whats_new_importer.ui deleted file mode 100644 index 5dfd8471..00000000 --- a/data/ui/whats_new_importer.ui +++ /dev/null @@ -1,75 +0,0 @@ - - - - - diff --git a/data/ui/whats_new_library.ui b/data/ui/whats_new_library.ui deleted file mode 100644 index 3d699a37..00000000 --- a/data/ui/whats_new_library.ui +++ /dev/null @@ -1,75 +0,0 @@ - - - - - diff --git a/data/ui/whats_new_m4b.ui b/data/ui/whats_new_m4b.ui deleted file mode 100644 index c97c53b4..00000000 --- a/data/ui/whats_new_m4b.ui +++ /dev/null @@ -1,75 +0,0 @@ - - - - - diff --git a/data/ui/whats_new_m4b_chapter.ui b/data/ui/whats_new_m4b_chapter.ui deleted file mode 100644 index 7d382344..00000000 --- a/data/ui/whats_new_m4b_chapter.ui +++ /dev/null @@ -1,75 +0,0 @@ - - - - - diff --git a/main.py b/main.py index 766540a6..00afe960 100755 --- a/main.py +++ b/main.py @@ -19,11 +19,14 @@ import argparse import code +import gettext +import locale import logging import os import signal import sys import traceback + import gi gi.require_version('Gtk', '4.0') @@ -32,17 +35,26 @@ gi.require_version('Gst', '1.0') gi.require_version('GstPbutils', '1.0') +from gi.repository import Gio, GLib + pkgdatadir = '@DATA_DIR@' localedir = '@LOCALE_DIR@' -from gi.repository import Gio +# We need to call `locale.*textdomain` to get the strings in UI files translated +locale.bindtextdomain('com.github.geigi.cozy', localedir) +locale.textdomain('com.github.geigi.cozy') + +# But also `gettext.*textdomain`, to make `_("foo")` in Python work as well +gettext.bindtextdomain('com.github.geigi.cozy', localedir) +gettext.textdomain('com.github.geigi.cozy') -# gresource must be registered before importing any Gtk.Template annotated classes +gettext.install('com.github.geigi.cozy', localedir) + + +# gresource must be registered before importing any Gtk.Template annotated classes resource = Gio.Resource.load(os.path.join(pkgdatadir, 'com.github.geigi.cozy.ui.gresource')) resource._register() -from gi.repository import GLib - old_except_hook = None log = logging.getLogger("main") @@ -93,7 +105,7 @@ def main(): listen() - application = Application(localedir, pkgdatadir) + application = Application(pkgdatadir) try: # Handle the debug option seperatly without the Glib stuff diff --git a/requirements.txt b/requirements.txt index 08b6b5e2..23408ea8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,5 @@ distro mutagen -packaging peewee>=3.9.6 pytz requests 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