class FileNotFoundDialog(): _importer: Importer = inject.attr(Importer) _library: Library = inject.attr(Library) def __init__(self, chapter: Chapter): self.missing_chapter = chapter self.parent = cozy.ui.main_view.CozyUI() self.builder = Gtk.Builder.new_from_resource( "/com/github/geigi/cozy/file_not_found.ui") self.dialog = self.builder.get_object("dialog") self.dialog.set_modal(self.parent.window) self.builder.get_object("file_label").set_markup("<tt>" + chapter.file + "</tt>") cancel_button = self.builder.get_object("cancel_button") cancel_button.connect("clicked", self.close) locate_button = self.builder.get_object("locate_button") locate_button.connect("clicked", self.locate) def show(self): self.dialog.show() def close(self, _): self.dialog.destroy() def locate(self, __): directory, filename = os.path.split(self.missing_chapter.file) dialog = Gtk.FileChooserDialog( "Please locate the file " + filename, self.parent.window, Gtk.FileChooserAction.OPEN, (Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OPEN, Gtk.ResponseType.OK)) filter = Gtk.FileFilter() filter.add_pattern(filename) filter.set_name(filename) dialog.add_filter(filter) path, file_extension = os.path.splitext(self.missing_chapter.file) filter = Gtk.FileFilter() filter.add_pattern("*" + file_extension) filter.set_name(file_extension + " files") dialog.add_filter(filter) filter = Gtk.FileFilter() filter.add_pattern("*") filter.set_name(_("All files")) dialog.add_filter(filter) dialog.set_local_only(False) response = dialog.run() if response == Gtk.ResponseType.OK: new_location = dialog.get_filename() self.missing_chapter.file = new_location self._importer.scan() self.dialog.destroy() dialog.destroy()
class WhatsNewWindow(Gtk.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(widget) self.continue_button.connect("clicked", self.__on_continue_clicked) self.show() def _fill_window(self): self.children = [] if version.parse(self.app_settings.last_launched_version ) < version.parse(INTRODUCED): self.children.append(WhatsNewM4B()) if not self.app_settings.last_launched_version: self.children.append(WhatsNewImporter()) self.children.append(ErrorReporting()) def __on_continue_clicked(self, widget): if len(self.children) == self.page + 1: self.end() return self.page += 1 self.content_stack.set_visible_child(self.children[self.page]) def end(self): self.app_settings.last_launched_version = CozyVersion self.close() self.destroy()
class PlaybackSpeedViewModel(Observable, EventSender): _player: Player = inject.attr(Player) def __init__(self): super().__init__() super(Observable, self).__init__() self._book: Book = self._player.loaded_book self._player.add_listener(self._on_player_event) @property def playback_speed(self) -> float: if self._book: return self._book.playback_speed else: return 1.0 @playback_speed.setter def playback_speed(self, new_value: float): if self._book: self._book.playback_speed = new_value self._player.playback_speed = new_value def _on_player_event(self, event: str, message): if event == "chapter-changed" and message: self._book = message self._notify("playback_speed")
class PlaybackSpeedPopover(Gtk.Popover): __gtype_name__ = "PlaybackSpeedPopover" _view_model: PlaybackSpeedViewModel = inject.attr(PlaybackSpeedViewModel) playback_speed_scale: Gtk.Scale = Gtk.Template.Child() playback_speed_label: Gtk.Label = Gtk.Template.Child() def __init__(self, **kwargs): super().__init__(**kwargs) self.playback_speed_scale.add_mark(1.0, Gtk.PositionType.RIGHT, None) self.playback_speed_scale.set_increments(0.02, 0.05) self.playback_speed_scale.connect( "value-changed", self._on_playback_speed_scale_changed) self._connect_view_model() self._on_playback_speed_changed() def _connect_view_model(self): self._view_model.bind_to("playback_speed", self._on_playback_speed_changed) def _on_playback_speed_scale_changed(self, _): speed = round(self.playback_speed_scale.get_value(), 2) self._view_model.playback_speed = speed self.playback_speed_label.set_markup( "<span font_features='tnum'>{speed:3.1f} x</span>".format( speed=speed)) def _on_playback_speed_changed(self): self.playback_speed_scale.set_value(self._view_model.playback_speed)
class PlaybackSpeed(EventSender): """ Contains the playback speed logic. """ ui = None speed = 1.0 _player: Player = inject.attr(Player) def __init__(self): super().__init__() self.ui = cozy.ui.main_view.CozyUI() self.builder = Gtk.Builder.new_from_resource( "/com/github/geigi/cozy/playback_speed_popover.ui") self.speed_scale = self.builder.get_object("playback_speed_scale") self.speed_label = self.builder.get_object("playback_speed_label") self.popover = self.builder.get_object("speed_popover") self.speed_scale.add_mark(1.0, Gtk.PositionType.RIGHT, None) self.speed_scale.set_increments(0.02, 0.05) self.speed_scale.connect("value-changed", self.__set_playback_speed) player.add_player_listener(self.__player_changed) def get_popover(self): return self.popover def get_speed(self): return self.speed def set_speed(self, speed): self.speed_scale.set_value(speed) self.__set_playback_speed(None) def __set_playback_speed(self, widget): """ Set and save the playback speed. Update playback speed label. """ self.speed = round(self.speed_scale.get_value(), 2) self.speed_label.set_text('{speed:3.1f} x'.format(speed=self.speed)) player.set_playback_speed(self.speed) book = self._player.loaded_book if book: book.playback_speed = self.speed self.emit_event("playback-speed-changed", self.speed) def __player_changed(self, event, message): """ Listen to and handle all gst player messages that are important for the ui. """ if event == "track-changed": track = message speed = track.book.playback_speed self.speed_scale.set_value(speed) self.__set_playback_speed(None)
class StorageBlockList: _db = cache = inject.attr(SqliteDatabase) def rebase_path(self, old_path: str, new_path: str): for element in StorageBlackList.select(): if old_path in element.path: new_file_path = element.path.replace(old_path, new_path) StorageBlackList.update(path=new_file_path).where( StorageBlackList.id == element.id).execute()
class SettingsViewModel(Observable, object): _importer: Importer = inject.attr(Importer) _model: Settings = inject.attr(Settings) def __init__(self): super().__init__() self._swap_author_reader: bool = None if self._model.first_start: self._importer.scan() @property def swap_author_reader(self): return self._swap_author_reader @swap_author_reader.setter def swap_author_reader(self, value): self._swap_author_reader = value for callback in self._observers["swap_author_reader"]: callback(value)
class Settings: _storages: List[Storage] = [] _db = cache = inject.attr(SqliteDatabase) def __init__(self): with self._db: self._db_object: SettingsModel = SettingsModel.get() @property def first_start(self) -> bool: return self._db_object.first_start @property def last_played_book(self) -> Book: return self._db_object.last_played_book @last_played_book.setter def last_played_book(self, new_value: Book): with self._db: self._db_object.last_played_book = 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) @property def storage_locations(self): if not self._storages: self._load_all_storage_locations() return self._storages @property def external_storage_locations(self): 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 _load_all_storage_locations(self): with self._db: 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))
class ErrorReporting(Gtk.Box): __gtype_name__ = 'ErrorReporting' level_label: Gtk.Label = Gtk.Template.Child() description_label: Gtk.Label = Gtk.Template.Child() details_label: Gtk.Label = Gtk.Template.Child() verbose_adjustment: Gtk.Adjustment = Gtk.Template.Child() verbose_scale: Gtk.Scale = Gtk.Template.Child() app_settings: ApplicationSettings = inject.attr(ApplicationSettings) def __init__(self, **kwargs): super().__init__(**kwargs) self.__init_scale() self.__connect() level = self.app_settings.report_level self.verbose_adjustment.set_value(level + 1) self._adjustment_changed(self.verbose_adjustment) def __init_scale(self): for i in range(1, 5): self.verbose_scale.add_mark(i, Gtk.PositionType.RIGHT, None) self.verbose_scale.set_round_digits(0) def __connect(self): self.verbose_adjustment.connect("value-changed", self._adjustment_changed) def _adjustment_changed(self, adjustment: Gtk.Adjustment): level = int(adjustment.get_value()) - 1 self.app_settings.report_level = level self._update_ui_texts(level) def _update_ui_texts(self, level: int): self.level_label.set_text(LEVELS[level]) self._update_description(level) self._update_details(level) def _update_description(self, value): detail_index = min(value, 1) self.description_label.set_text(LEVEL_DESCRIPTION[detail_index]) def _update_details(self, value): details = "" for i in range(value + 1): for line in LEVEL_DETAILS[i]: details += "• {}\n".format(line) self.details_label.set_text(details)
class InfoBanner: _builder: Gtk.Builder = inject.attr("MainWindowBuilder") def __init__(self): super(InfoBanner, self).__init__() self._overlay: Gtk.Overlay = self._builder.get_object("banner_overlay") self._toast: Granite.WidgetsToast = Granite.WidgetsToast.new("") self._toast.show_all() self._overlay.add_overlay(self._toast) self._banner_displayed: bool = False def show(self, message: str): self._toast.set_property("title", message) self._toast.set_reveal_child(True)
class PowerManager: _player: Player = inject.attr(Player) _gtk_app = inject.attr("GtkApp") def __init__(self): self._inhibit_cookie = None self._player.add_listener(self._on_player_changed) def _on_player_changed(self, event: str, data): if event in ["pause", "stop"]: if self._inhibit_cookie: log.info("Uninhibited standby.") self._gtk_app.uninhibit(self._inhibit_cookie) self._inhibit_cookie = None elif event == "play": if self._inhibit_cookie: return self._inhibit_cookie = self._gtk_app.inhibit( None, Gtk.ApplicationInhibitFlags.SUSPEND, "Playback of audiobook") log.info("Inhibited standby.")
class DeleteBookView(Gtk.Dialog): __gtype_name__ = 'DeleteBookDialog' main_window = inject.attr("MainWindow") def __init__(self, **kwargs): super().__init__(**kwargs) self.set_modal(self.main_window.window) def get_delete_book(self): response = self.run() if response == Gtk.ResponseType.APPLY: return True else: return False
class Warnings(): _fs_monitor: FilesystemMonitor = inject.attr("FilesystemMonitor") def __init__(self, button: Gtk.MenuButton): self.button = button self.builder = Gtk.Builder.new_from_resource( "/com/github/geigi/cozy/warning_popover.ui") self.popover = self.builder.get_object("warning_popover") self.warning_container: Gtk.Box = self.builder.get_object( "warning_container") self._fs_monitor.add_listener(self.__on_storage_changed) for storage in self._fs_monitor.get_offline_storages(): self.append_text( gettext('{storage} is offline.').format(storage=storage)) self.__hide_show_button() def get_popover(self): return self.popover def append_text(self, text): label = Gtk.Label() self.warning_container.add(label) label.set_visible(True) label.set_text(text) def __on_storage_changed(self, event, message): if event == "storage-offline": self.append_text( gettext('{storage} is offline.').format(storage=message)) if event == "storage-online": for label in self.warning_container.get_children(): if message in label.get_text(): self.warning_container.remove(label) self.__hide_show_button() def __hide_show_button(self): if len(self.warning_container.get_children()) > 0: self.button.set_visible(True) else: self.button.set_visible(False)
class StorageBlockList: _db = cache = inject.attr(SqliteDatabase) def rebase_path(self, old_path: str, new_path: str): for element in StorageBlackList.select(): if old_path in element.path: new_file_path = element.path.replace(old_path, new_path) StorageBlackList.update(path=new_file_path).where( StorageBlackList.id == element.id).execute() def add_book(self, book: Book): book_tracks = [ TrackModel.get_by_id(chapter.id) for chapter in book.chapters ] data = list((t.file, ) for t in book_tracks) chunks = [data[x:x + 500] for x in range(0, len(data), 500)] for chunk in chunks: StorageBlackList.insert_many(chunk, fields=[StorageBlackList.path ]).execute()
class BookSearchResult(SearchResult): """ This class represents a book search result. """ _artwork_cache: ArtworkCache = inject.attr(ArtworkCache) def __init__(self, book: Book, on_click): super().__init__(on_click, book) self.set_tooltip_text(_("Play this book")) scale = self.get_scale_factor() pixbuf = self._artwork_cache.get_cover_pixbuf(book.db_object, scale, BOOK_ICON_SIZE) if pixbuf: surface = Gdk.cairo_surface_create_from_pixbuf(pixbuf, scale, None) img = Gtk.Image.new_from_surface(surface) else: img = Gtk.Image.new_from_icon_name("book-open-variant-symbolic", Gtk.IconSize.MENU) img.props.pixel_size = BOOK_ICON_SIZE img.set_size_request(BOOK_ICON_SIZE, BOOK_ICON_SIZE) title_label = Gtk.Label() title_label.set_text(tools.shorten_string(book.name, MAX_BOOK_LENGTH)) title_label.set_halign(Gtk.Align.START) title_label.props.margin = 4 title_label.props.hexpand = True title_label.props.hexpand_set = True title_label.set_margin_right(5) title_label.props.width_request = 100 title_label.props.xalign = 0.0 title_label.set_line_wrap(True) self.box.add(img) self.box.add(title_label) self.add(self.box) self.show_all()
class StorageListBoxRow(Gtk.ListBoxRow): """ This class represents a listboxitem for a storage location. """ _library: Library = inject.attr(Library) _block_list: StorageBlockList = inject.attr(StorageBlockList) _settings: Settings = inject.attr(Settings) def __init__(self, parent, db_id, path, external, default=False): super(Gtk.ListBoxRow, self).__init__() self.ui = cozy.ui.main_view.CozyUI() self.db_id = db_id self.path = path self.default = default self.external = external self.parent = parent 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_left(4) box.set_margin_right(4) box.set_margin_top(5) box.set_margin_bottom(5) self.default_image = Gtk.Image() self.default_image.set_from_icon_name("emblem-default-symbolic", Gtk.IconSize.LARGE_TOOLBAR) self.default_image.set_margin_right(5) self.type_image = self.__get_type_image() self.location_chooser = Gtk.FileChooserButton() self.location_chooser.set_local_only(False) self.location_chooser.set_action(Gtk.FileChooserAction.SELECT_FOLDER) if path != "": self.location_chooser.set_current_folder(path) self.location_chooser.set_halign(Gtk.Align.START) self.location_chooser.props.hexpand = True self.location_chooser.connect("file-set", self.__on_folder_changed) box.add(self.type_image) box.add(self.location_chooser) box.add(self.default_image) self.add(box) self.show_all() self.default_image.set_visible(default) def set_default(self, default): """ Set this storage location as the default :param default: Boolean """ self.default = default self.default_image.set_visible(default) Storage.update(default=default).where( Storage.id == self.db_id).execute() def get_default(self): """ Is this storage location the default one? """ return self.default def set_selected(self, selected): """ Set UI colors for the default img. :param selected: Boolean """ if selected: self.default_image.get_style_context().add_class("selected") else: self.default_image.get_style_context().remove_class("selected") def set_external(self, external): """ Set this entry as external/internal storage. This method also writes the setting to the cozy. """ self.external = external if external: self.type_image.set_from_icon_name("network-server-symbolic", Gtk.IconSize.LARGE_TOOLBAR) self.type_image.set_tooltip_text(_("External drive")) else: self.type_image.set_from_icon_name("drive-harddisk-symbolic", Gtk.IconSize.LARGE_TOOLBAR) self.type_image.set_tooltip_text(_("Internal drive")) Storage.update(external=external).where( Storage.id == self.db_id).execute() def __on_folder_changed(self, widget): """ Update the location in the database. Start an import scan or a rebase operation. """ new_path = self.location_chooser.get_file().get_path() # First test if the new location is already in the database if Storage.select().where(Storage.path == new_path).count() > 0: return # If not, add it to the database old_path = Storage.select().where(Storage.id == self.db_id).get().path self.path = new_path Storage.update(path=new_path).where(Storage.id == self.db_id).execute() self._settings.invalidate() # Run a reimport or rebase if old_path == "": self.parent.emit_event("storage-added", self.path) log.info("New audiobook location added. Starting import scan.") self.ui.scan(None, None) else: self.parent.emit_event("storage-changed", self.path) log.info( "Audio book location changed, rebasing the location in cozy.") self.ui.switch_to_working(_("Changing audio book location…"), False) self._block_list.rebase_path(old_path, new_path) thread = Thread(target=self._library.rebase_path, args=(old_path, new_path), name="RebaseStorageLocationThread") thread.start() def __get_type_image(self): """ Returns the matching drive icon for this storage location. :return: External or internal drive gtk image. """ type_image = Gtk.Image() if self.external: icon_name = "network-server-symbolic" type_image.set_tooltip_text(_("External drive")) else: icon_name = "drive-harddisk-symbolic" type_image.set_tooltip_text(_("Internal drive")) type_image.set_from_icon_name(icon_name, Gtk.IconSize.LARGE_TOOLBAR) type_image.set_margin_right(5) return type_image
class Library(EventSender): _db = cache = inject.attr(SqliteDatabase) _books: List[Book] = [] _chapters: Set[Chapter] = set() _files: Set[str] = set() def __init__(self): super().__init__() @property def authors(self): authors = {book.author for book in self.books} authors = split_strings_to_set(authors) return authors @property def readers(self): readers = {book.reader for book in self.books} readers = split_strings_to_set(readers) return readers @property def books(self): if not self._books: self._load_all_books() return self._books @property def chapters(self) -> Set[Chapter]: if not self._chapters: self._load_all_chapters() return self._chapters @property def files(self) -> Set[str]: if not self._files: self._load_all_files() return self._files def invalidate(self): for book in self._books: book.destroy() self._books = [] for chapter in self._chapters: chapter.destroy() self._chapters = set() self._files = set() @timing def rebase_path(self, old_path: str, new_path: str): chapter_count = len(self.chapters) progress = 0 with self._db: for chapter in self.chapters: if chapter.file.startswith(old_path): progress += 1 chapter.file = chapter.file.replace(old_path, new_path) self.emit_event_main_thread("rebase-progress", progress / chapter_count) self.emit_event_main_thread("rebase-finished") def insert_many(self, media_files: Set[MediaFile]): tracks = self._prepare_db_objects(media_files) with self._db: Track.insert_many(tracks).execute() def _prepare_db_objects(self, media_files: Set[MediaFile]) -> Set[object]: book_db_objects: Set[BookModel] = set() for media_file in media_files: if not media_file: continue with self._db: book = next((book for book in book_db_objects if book.name == media_file.book_name), None) if not book: book = self._import_or_update_book(media_file) book_db_objects.add(book) if len(media_file.chapters) == 1: track = self._get_track_dictionary_for_db(media_file, book) else: raise NotImplementedError if media_file.path not in self.files: yield track else: self._update_track_db_object(media_file, book) def _import_or_update_book(self, media_file): if BookModel.select(BookModel.name).where( BookModel.name == media_file.book_name).count() < 1: book = self._create_book_db_object(media_file) else: book = self._update_book_db_object(media_file) return book def _get_track_dictionary_for_db(self, media_file: MediaFile, book: BookModel): return { "name": media_file.chapters[0].name, "number": media_file.track_number, "disk": media_file.disk, "book": book, "file": media_file.path, "length": media_file.length, "modified": media_file.modified, "position": media_file.chapters[0].position } def _update_track_db_object(self, media_file: MediaFile, book: BookModel): Track.update(name=media_file.chapters[0].name, number=media_file.track_number, book=book, disk=media_file.disk, length=media_file.length, modified=media_file.modified) \ .where(Track.file == media_file.path) \ .execute() def _update_book_db_object(self, media_file: MediaFile) -> BookModel: BookModel.update(name=media_file.book_name, author=media_file.author, reader=media_file.reader, cover=media_file.cover) \ .where(BookModel.name == media_file.book_name) \ .execute() return BookModel.select().where( BookModel.name == media_file.book_name).get() def _create_book_db_object(self, media_file: MediaFile) -> BookModel: return BookModel.create(name=media_file.book_name, author=media_file.author, reader=media_file.reader, cover=media_file.cover, position=0, rating=-1) def _load_all_books(self): with self._db: for book_db_obj in BookModel.select(BookModel.id): try: book = Book(self._db, book_db_obj.id) book.add_listener(self._on_book_event) self._books.append(book) except BookIsEmpty: pass def _load_all_chapters(self): self._chapters = { chapter for book_chapters in [book.chapters for book in self.books] for chapter in book_chapters } for chapter in self._chapters: chapter.add_listener(self._on_chapter_event) def _load_all_files(self): self._files = {chapter.file for chapter in self.chapters} def _on_chapter_event(self, event: str, chapter: Chapter): if event == "chapter-deleted": try: self.chapters.remove(chapter) except KeyError: log.error( "Could not remove chapter from library chapter list.") try: self.files.remove(chapter.file) except KeyError: log.error("Could not remove file from library file list.") self._files = [] def _on_book_event(self, event: str, book): if event == "book-deleted": self.books.remove(book)
class PlaybackControlViewModel(Observable, EventSender): _player: Player = inject.attr(Player) def __init__(self): super().__init__() super(Observable, self).__init__() self._book: Optional[Book] = None self._player.add_listener(self._on_player_event) if self._player.loaded_book: self.book = self._player.loaded_book @property def book(self) -> Optional[Book]: return self._book @book.setter def book(self, value: Optional[Book]): if self._book: self._book.remove_bind("playback_speed", self._on_playback_speed_changed) self._book = value if value: self._book.bind_to("playback_speed", self._on_playback_speed_changed) self._notify("lock_ui") @property def playing(self) -> bool: if not self._player.loaded_book: return False return self._player.playing @property def position(self) -> Optional[float]: if not self._book: return None return self._book.current_chapter.position / 1000000000 / self._book.playback_speed @position.setter def position(self, new_value: int): self._player.position = new_value @property def length(self) -> Optional[float]: if not self._player.loaded_book or not self._book: return None return self._player.loaded_book.current_chapter.length / self._book.playback_speed @property def lock_ui(self) -> bool: return not self._book @property def volume(self) -> float: return self._player.volume @volume.setter def volume(self, new_value: float): self._player.volume = new_value def play_pause(self): self._player.play_pause() def rewind(self): self._player.rewind() def open_book_detail(self): if self.book: self.emit_event(OpenView.BOOK, self.book) def _on_player_event(self, event, message): if event == "play" or event == "pause": if self.book: self._notify("playing") elif event == "position": if self.book: self._notify("position") self._notify("progress_percent") self._notify("remaining_text") elif event == "track-changed": if message: self.book = message self._notify("book") self._notify("position") self._notify("length") self._notify("volume") elif event == "stop": self.book = None self._notify("book") self._notify("position") self._notify("length") def _on_playback_speed_changed(self): self._notify("position") self._notify("length")
class BookOverview: """ This class contains all logic for the book overview. """ _settings = inject.attr(Settings) _artwork_cache: ArtworkCache = inject.attr(ArtworkCache) book = None current_track_element = None switch_signal = None def __init__(self): self.ui = cozy.ui.main_view.CozyUI() builder = self.ui.window_builder self.name_label = builder.get_object("info_book_label") self.author_label = builder.get_object("info_author_label") self.download_box = builder.get_object("info_download_box") self.download_label = builder.get_object("info_download_label") self.download_image = builder.get_object("info_download_image") self.download_switch = builder.get_object("info_download_switch") self.published_label = builder.get_object("info_published_label") self.last_played_label = builder.get_object("info_last_played_label") self.total_label = builder.get_object("info_total_label") self.remaining_label = builder.get_object("info_remaining_label") self.progress_bar = builder.get_object("book_progress_bar") self.cover_img = builder.get_object("info_cover_image") self.track_list_container = builder.get_object("track_list_container") self.published_text = builder.get_object("info_published_text") self.remaining_text = builder.get_object("info_remaining_text") self.play_book_button = builder.get_object("play_book_button") self.play_book_button.connect("clicked", self.__on_play_clicked) self.play_img = builder.get_object("play_img1") self.pause_img = builder.get_object("pause_img1") self.scroller = builder.get_object("book_overview_scroller") if Gtk.get_minor_version() > 20: self.scroller.props.propagate_natural_height = True self.ui.speed.add_listener(self.__ui_changed) player.add_player_listener(self.__player_changed) self._settings.add_listener(self.__settings_changed) OfflineCache().add_listener(self.__on_offline_cache_changed) def set_book(self, book): """ Display the given book in the book overview. """ if self.book and self.book.id == book.id: self.update_time() return self.book = Book.get(Book.id == book.id) if player.is_playing( ) and self.ui.titlebar.current_book and self.book.id == self.ui.titlebar.current_book.id: self.play_book_button.set_image(self.pause_img) else: self.play_book_button.set_image(self.play_img) self.name_label.set_text(book.name) self.author_label.set_text(book.author) self.update_offline_status() pixbuf = self._artwork_cache.get_cover_pixbuf( book, self.ui.window.get_scale_factor(), 250) if pixbuf: surface = Gdk.cairo_surface_create_from_pixbuf( pixbuf, self.ui.window.get_scale_factor(), None) self.cover_img.set_from_surface(surface) else: self.cover_img.set_from_icon_name("book-open-variant-symbolic", Gtk.IconSize.DIALOG) self.cover_img.props.pixel_size = 250 self.duration = get_book_duration(book) self.speed = self.book.playback_speed self.total_label.set_text( tools.seconds_to_human_readable(self.duration / self.speed)) self.last_played_label.set_text( tools.past_date_to_human_readable(book.last_played)) self.published_label.set_visible(False) self.published_text.set_visible(False) # track list # This box contains all track content self.track_box = Gtk.Box() self.track_box.set_orientation(Gtk.Orientation.VERTICAL) self.track_box.set_halign(Gtk.Align.START) self.track_box.set_valign(Gtk.Align.START) self.track_box.props.margin = 8 disk_number = -1 first_disk_element = None disk_count = 0 for track in get_tracks(book): # Insert disk headers if track.disk != disk_number: disc_element = DiskElement(track.disk) self.track_box.add(disc_element) if disk_number == -1: first_disk_element = disc_element if track.disk < 2: first_disk_element.set_hidden(True) else: first_disk_element.show_all() disc_element.show_all() disk_number = track.disk disk_count += 1 track_element = TrackElement(track, self) self.track_box.add(track_element) track_element.show_all() self.track_list_container.remove_all_children() self.track_box.show() self.track_box.set_halign(Gtk.Align.FILL) self.track_list_container.add(self.track_box) self._mark_current_track() self.update_time() def update_offline_status(self): """ Hide/Show download elements depending on whether the book is on an external storage. """ self.book = Book.get(Book.id == self.book.id) if self.switch_signal: self.download_switch.disconnect(self.switch_signal) if is_external(self.book): self.download_box.set_visible(True) self.download_switch.set_visible(True) self.download_switch.set_active(self.book.offline) else: self.download_box.set_visible(False) self.download_switch.set_visible(False) self.switch_signal = self.download_switch.connect( "notify::active", self.__on_download_switch_changed) self._set_book_download_status(self.book.downloaded) def update_time(self): if self.book is None: return # update book object # TODO: optimize usage by only asking from the db on track change query = Book.select().where(Book.id == self.book.id) if (query.exists()): self.book = query.get() else: self.book = None return if self.ui.titlebar.current_book and self.book.id == self.ui.titlebar.current_book.id: progress = get_book_progress(self.book, False) progress += (player.get_current_duration() / 1000000000) remaining = (self.duration - progress) else: progress = get_book_progress(self.book) remaining = get_book_remaining(self.book) percentage = progress / self.duration self.total_label.set_text( tools.seconds_to_human_readable(self.duration / self.speed)) if percentage > 0.005: self.remaining_text.set_visible(True) self.remaining_label.set_visible(True) self.remaining_label.set_text( tools.seconds_to_human_readable(remaining / self.speed)) else: self.remaining_text.set_visible(False) self.remaining_label.set_visible(False) self.progress_bar.set_fraction(percentage) def _set_active_track(self, curr_track, playing): if self.book is None or self.ui.titlebar.current_book.id != self.book.id: return self.deselect_track_element() if self.book.position == -1: return track_box_children = [ e for e in self.track_box.get_children() if isinstance(e, TrackElement) ] if curr_track: self.current_track_element = next( filter(lambda x: x.track.id == curr_track.id, track_box_children), None) if self.current_track_element is None: self.current_track_element = track_box_children[0] self.current_track_element.select() self.current_track_element.set_playing(playing) def deselect_track_element(self): if self.current_track_element: self.current_track_element.set_playing(False) self.current_track_element.deselect() def block_ui_elements(self, block): """ Blocks the download button. This gets called when a db scan is active. """ self.download_switch.set_sensitive(not block) def _set_book_download_status(self, downloaded): if downloaded: self.download_image.set_from_icon_name("downloaded-symbolic", Gtk.IconSize.LARGE_TOOLBAR) self.download_label.set_text(_("Downloaded")) else: self.download_image.set_from_icon_name("download-symbolic", Gtk.IconSize.LARGE_TOOLBAR) self.download_label.set_text(_("Download")) def _mark_current_track(self): """ Mark the current track position. """ book = Book.get(Book.id == self.book.id) if book.position == -1: self.deselect_track_element() self.current_track_element = None return for track_element in self.track_box.get_children(): if isinstance(track_element, DiskElement): continue elif track_element.track.id == book.position: self.current_track_element = track_element track_element.select() else: track_element.deselect() if book.position == 0: self.current_track_element = self.track_box.get_children()[1] self.current_track_element.select() if self.ui.titlebar.current_book and self.ui.titlebar.current_book.id == self.book.id: self.current_track_element.set_playing(player.is_playing()) def __ui_changed(self, event, message): """ Handler for events that occur in the main ui. """ if self.book is None or self.ui.titlebar.current_book is None or self.ui.titlebar.current_book.id != self.book.id: return if event == "playback-speed-changed": self.speed = Book.select().where( Book.id == self.book.id).get().playback_speed if self.ui.main_stack.props.visible_child_name == "book_overview": self.update_time() def __player_changed(self, event, message): """ React to player changes. """ if self.book is None or self.ui.titlebar.current_book is None or self.ui.titlebar.current_book.id != self.book.id: return if event == "play": self.play_book_button.set_image(self.pause_img) self.current_track_element.set_playing(True) self.last_played_label.set_text( tools.past_date_to_human_readable(message.book.last_played)) elif event == "pause": self.play_book_button.set_image(self.play_img) self.current_track_element.set_playing(False) elif event == "stop": self._mark_current_track() elif event == "track-changed": track = player.get_current_track() self._set_active_track(track, player.is_playing()) def __settings_changed(self, event, message): """ React to changes in user settings. """ if not self.book: return if event == "storage-removed" or event == "external-storage-removed": if message in get_tracks(self.book).first().file: self.download_box.set_visible(False) self.download_switch.set_visible(False) elif "external-storage-added" or event == "storage-changed" or event == "storage-added": self.update_offline_status() def __on_play_clicked(self, event): """ Play button clicked. Start/pause playback. """ track = get_track_for_playback(self.book) current_track = player.get_current_track() if current_track and current_track.book.id == self.book.id: player.play_pause(None) if player.get_gst_player_state() == Gst.State.PLAYING: player.jump_to_ns(track.position) else: player.load_file(track) player.play_pause(None, True) return True def __on_download_switch_changed(self, switch, state): if self.download_switch.get_active(): Book.update(offline=True).where(Book.id == self.book.id).execute() OfflineCache().add(self.book) else: Book.update( offline=False, downloaded=False).where(Book.id == self.book.id).execute() OfflineCache().remove(self.book) self._set_book_download_status(False) def __on_offline_cache_changed(self, event, message): """ """ if message is Book and message.id != self.book.id: return if event == "book-offline": self._set_book_download_status(True) elif event == "book-offline-removed": self._set_book_download_status(False)
class Settings(EventSender): """ This class contains all logic for cozys preferences. """ _glib_settings: Gio.Settings = inject.attr(Gio.Settings) view_model = None ui = None default_dark_mode = None def __init__(self): super().__init__() from cozy.control.artwork_cache import ArtworkCache self._artwork_cache: ArtworkCache = inject.instance(ArtworkCache) self.view_model = SettingsViewModel() self.ui = cozy.ui.main_view.CozyUI() self.builder = Gtk.Builder.new_from_resource( "/com/github/geigi/cozy/settings.ui") # get settings window self.window = self.builder.get_object("settings_window") self.window.set_transient_for(self.ui.window) self.window.connect("delete-event", self.ui.hide_window) self.add_storage_button = self.builder.get_object("add_location_button") self.add_storage_button.connect("clicked", self.__on_add_storage_clicked) self.remove_storage_button = self.builder.get_object("remove_location_button") self.remove_storage_button.connect("clicked", self.__on_remove_storage_clicked) self.external_button = self.builder.get_object("external_button") self.external_button_handle_id = self.external_button.connect("clicked", self.__on_external_clicked) self.default_storage_button = self.builder.get_object("default_location_button") self.default_storage_button.connect("clicked", self.__on_default_storage_clicked) self.storage_list_box = self.builder.get_object("storage_list_box") self.storage_list_box.connect("row-activated", self.__on_storage_box_changed) self.remove_blacklist_button = self.builder.get_object("remove_blacklist_button") self.remove_blacklist_button.connect("clicked", self.__on_remove_blacklist_clicked) # self.remove_blacklist_button.set_sensitive(False) self.blacklist_tree_view = self.builder.get_object("blacklist_tree_view") self.blacklist_model = self.builder.get_object("blacklist_store") self.blacklist_tree_view.get_selection().connect("changed", self.__on_blacklist_selection_changed) self.sleep_fadeout_switch = self.builder.get_object("sleep_fadeout_switch") self.sleep_fadeout_switch.connect("notify::active", self.__on_fadeout_switch_changed) self.external_cover_switch = self.builder.get_object("external_cover_switch") self.external_cover_switch.connect("state-set", self.__on_external_cover_switch_changed) self.fadeout_duration_label = self.builder.get_object("fadeout_duration_label") self.fadeout_duration_row = self.builder.get_object("fadeout_duration_row") self.fadeout_duration_adjustment = self.builder.get_object("fadeout_duration_adjustment") self.fadeout_duration_adjustment.connect("value-changed", self.__on_fadeout_adjustment_changed) self.__on_fadeout_adjustment_changed(self.fadeout_duration_adjustment) self.force_refresh_button = self.builder.get_object("force_refresh_button") self.force_refresh_button.connect("clicked", self.__on_force_refresh_clicked) self.settings_stack: Gtk.Stack = self.builder.get_object("settings_stack") self.settings_stack.connect("notify::visible-child", self._on_settings_stack_changed) from cozy.ui.widgets.error_reporting import ErrorReporting self.settings_stack.add_titled(ScrollWrapper(ErrorReporting()), "feedback", _("Feedback")) self._init_storage() self._init_blacklist() self.__init_bindings() self.__on_storage_box_changed(None, None) self.set_darkmode() def _init_storage(self): """ Display settings from the database in the ui. """ found_default = False self.storage_list_box.remove_all_children() for location in Storage.select(): row = StorageListBoxRow(self, location.id, location.path, location.external, location.default) self.storage_list_box.add(row) if location.default: if found_default: row.set_default(False) else: found_default = True self.storage_list_box.select_row(row) self.__on_storage_box_changed(None, None) def _init_blacklist(self): """ Init the Storage location list. """ for file in StorageBlackList.select(): self.blacklist_model.append([file.path, file.id]) self.__on_blacklist_selection_changed(None) def __init_bindings(self): """ Bind Gio.Settings to widgets in settings dialog. """ sl_switch = self.builder.get_object("symlinks_switch") self._glib_settings.bind("symlinks", sl_switch, "active", Gio.SettingsBindFlags.DEFAULT) auto_scan_switch = self.builder.get_object("auto_scan_switch") self._glib_settings.bind("autoscan", auto_scan_switch, "active", Gio.SettingsBindFlags.DEFAULT) timer_suspend_switch = self.builder.get_object( "timer_suspend_switch") self._glib_settings.bind("suspend", timer_suspend_switch, "active", Gio.SettingsBindFlags.DEFAULT) replay_switch = self.builder.get_object("replay_switch") self._glib_settings.bind("replay", replay_switch, "active", Gio.SettingsBindFlags.DEFAULT) titlebar_remaining_time_switch = self.builder.get_object("titlebar_remaining_time_switch") self._glib_settings.bind("titlebar-remaining-time", titlebar_remaining_time_switch, "active", Gio.SettingsBindFlags.DEFAULT) dark_mode_switch = self.builder.get_object("dark_mode_switch") self._glib_settings.bind("dark-mode", dark_mode_switch, "active", Gio.SettingsBindFlags.DEFAULT) swap_author_reader_switch = self.builder.get_object("swap_author_reader_switch") self._glib_settings.bind("swap-author-reader", swap_author_reader_switch, "active", Gio.SettingsBindFlags.DEFAULT) self._glib_settings.bind("prefer-external-cover", self.external_cover_switch, "active", Gio.SettingsBindFlags.DEFAULT) self._glib_settings.bind("sleep-timer-fadeout", self.sleep_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.connect("changed", self.__on_settings_changed) def show(self): """ Show the settings window. """ self.window.show() def get_storage_elements_blocked(self): """ Are the location ui elements currently blocked? """ return not self.storage_list_box.get_sensitive() def block_ui_elements(self, block): """ Block/unblock UI storage elements. """ sensitive = not block self.storage_list_box.set_sensitive(sensitive) self.add_storage_button.set_sensitive(sensitive) self.external_button.set_sensitive(sensitive) row = self.storage_list_box.get_selected_row() if row and row.get_default() != True: self.remove_storage_button.set_sensitive(sensitive) self.default_storage_button.set_sensitive(sensitive) def __on_add_storage_clicked(self, widget): """ Add a new storage selector to the ui. """ db_obj = Storage.create(path="") self.storage_list_box.add(StorageListBoxRow(self, db_obj.id, "", False, False)) def __on_remove_storage_clicked(self, widget): """ Remove a storage selector from the ui and database. """ row = self.storage_list_box.get_selected_row() Storage.select().where(Storage.path == row.path).get().delete_instance() self.storage_list_box.remove(row) self.emit_event("storage-removed", row.path) thread = Thread(target=remove_tracks_with_path, args=(self.ui, row.path), name=("RemoveStorageFromDB")) thread.start() self.__on_storage_box_changed(None, None) def __on_default_storage_clicked(self, widget): """ Select a location as default storage. """ for row in self.storage_list_box.get_children(): row.set_default(False) self.storage_list_box.get_selected_row().set_default(True) self.__on_storage_box_changed(None, None) def __on_storage_box_changed(self, widget, row): """ Disable/enable toolbar buttons """ row = self.storage_list_box.get_selected_row() if row is None: sensitive = False default_sensitive = False else: sensitive = True if row.get_default(): default_sensitive = False else: default_sensitive = True self.external_button.handler_block(self.external_button_handle_id) self.external_button.set_active(row.external) self.external_button.handler_unblock(self.external_button_handle_id) self.remove_storage_button.set_sensitive(default_sensitive) self.external_button.set_sensitive(sensitive) self.default_storage_button.set_sensitive(default_sensitive) for child in self.storage_list_box.get_children(): if row and child.db_id == row.db_id: child.set_selected(True) else: child.set_selected(False) def __on_settings_changed(self, settings, key): """ Updates cozy's ui to changed Gio settings. """ if key == "titlebar-remaining-time": self.ui.titlebar._on_progress_setting_changed() elif key == "dark-mode": self.set_darkmode() def __on_remove_blacklist_clicked(self, widget): """ Remove the selected storage from the db and ui. TODO: Does this trigger a rescan? """ model, pathlist = self.blacklist_tree_view.get_selection().get_selected_rows() if pathlist: ids = [] for path in reversed(pathlist): treeiter = model.get_iter(path) ids.append(self.blacklist_model.get_value(treeiter, 1)) self.blacklist_model.remove(treeiter) StorageBlackList.delete().where(StorageBlackList.id in ids).execute() self.__on_blacklist_selection_changed(self.blacklist_tree_view.get_selection()) def __on_blacklist_selection_changed(self, tree_selection): """ The user selected a different storage location. Here we enable or disable the remove button depending on weather this is the default storage or not. """ if tree_selection is None or len(tree_selection.get_selected_rows()[1]) < 1: self.remove_blacklist_button.set_sensitive(False) else: self.remove_blacklist_button.set_sensitive(True) def __on_external_clicked(self, widget): """ The external/internal button was clicked. The new setting will be written to the cozy. """ external = self.external_button.get_active() row = self.storage_list_box.get_selected_row() row.set_external(external) if external: self.emit_event("external-storage-added", row.path) else: self.emit_event("external-storage-removed", row.path) def __on_fadeout_adjustment_changed(self, adjustment): """ This refreshes the label belonging to the fadeout duration adjustment. """ self.fadeout_duration_label.set_text(str(int(adjustment.get_value())) + " s") def __on_fadeout_switch_changed(self, switch, state): """ Enable/Disable sensitivity for the fadeout duration settings row. """ self.fadeout_duration_row.set_sensitive(switch.get_active()) def __on_external_cover_switch_changed(self, switch, state): """ Set the glib setting prefer-external-cover. This is needed because the binding gets called after this function. Then refresh the artwork cache. """ # We have to test if everything is initialized before triggering the refresh # otherwise this might be just the initial call when starting up if self.ui.is_initialized: self._glib_settings.set_boolean("prefer-external-cover", state) self._artwork_cache.delete_artwork_cache() self.ui.refresh_content() def __on_force_refresh_clicked(self, widget): """ Start a force refresh of the database. """ self.ui.scan(None, None) def _on_settings_stack_changed(self, widget, property): page = widget.props.visible_child_name if page == "files": self.blacklist_model.clear() self._init_blacklist() def set_darkmode(self): """ Enable or disable the dark gtk theme. """ settings = Gtk.Settings.get_default() if self.default_dark_mode is None: self.default_dark_mode = settings.get_property("gtk-application-prefer-dark-theme") user_enabled = self._glib_settings.get_boolean("dark-mode") if user_enabled: settings.set_property("gtk-application-prefer-dark-theme", True) else: settings.set_property("gtk-application-prefer-dark-theme", self.default_dark_mode)
class Book(Observable, EventSender): _chapters: List[Chapter] = None _settings: Settings = inject.attr(Settings) _app_settings: ApplicationSettings = inject.attr(ApplicationSettings) def __init__(self, db: SqliteDatabase, id: int): super().__init__() super(Observable, self).__init__() self._db: SqliteDatabase = db self.id: int = id self._get_db_object() def _get_db_object(self): self._db_object: BookModel = BookModel.get(self.id) if TrackModel.select().where( TrackModel.book == self._db_object).count() < 1: raise BookIsEmpty # This property is for the transition time only # Because everything is hardwired to the database objects # Step by step, you got this... @property def db_object(self): return self._db_object @property def name(self): return self._db_object.name @name.setter def name(self, new_name: str): self._db_object.name = new_name self._db_object.save(only=self._db_object.dirty_fields) @property def author(self): if not self._app_settings.swap_author_reader: return self._db_object.author else: return self._db_object.reader @author.setter def author(self, new_author: str): if not self._app_settings.swap_author_reader: self._db_object.author = new_author else: self._db_object.reader = new_author self._db_object.save(only=self._db_object.dirty_fields) @property def reader(self): if not self._app_settings.swap_author_reader: return self._db_object.reader else: return self._db_object.author @reader.setter def reader(self, new_reader: str): if not self._app_settings.swap_author_reader: self._db_object.reader = new_reader else: self._db_object.author = new_reader self._db_object.save(only=self._db_object.dirty_fields) @property def position(self) -> int: return self._db_object.position @position.setter def position(self, new_position: int): self._db_object.position = new_position self._db_object.save(only=self._db_object.dirty_fields) self._notify("position") self._notify("current_chapter") @property def rating(self): return self._db_object.rating @rating.setter def rating(self, new_rating: int): self._db_object.rating = new_rating self._db_object.save(only=self._db_object.dirty_fields) @property def cover(self): return self._db_object.cover @cover.setter def cover(self, new_cover: bytes): self._db_object.cover = new_cover self._db_object.save(only=self._db_object.dirty_fields) @property def playback_speed(self): return self._db_object.playback_speed @playback_speed.setter def playback_speed(self, new_playback_speed: float): self._db_object.playback_speed = new_playback_speed self._db_object.save(only=self._db_object.dirty_fields) self._notify("playback_speed") @property def last_played(self): return self._db_object.last_played @last_played.setter def last_played(self, new_last_played: int): self._db_object.last_played = new_last_played self._db_object.save(only=self._db_object.dirty_fields) self._notify("last_played") @property def offline(self): return self._db_object.offline @offline.setter def offline(self, new_offline: bool): self._db_object.offline = new_offline self._db_object.save(only=self._db_object.dirty_fields) @property def downloaded(self): return self._db_object.downloaded @downloaded.setter def downloaded(self, new_downloaded: bool): self._db_object.downloaded = new_downloaded self._db_object.save(only=self._db_object.dirty_fields) @property def chapters(self): if not self._chapters: self._fetch_chapters() return self._chapters @property def current_chapter(self): return next( (chapter for chapter in self.chapters if chapter.id == self.position), self.chapters[0]) @property def duration(self): return sum((chapter.length for chapter in self.chapters)) @property def progress(self): progress = 0 if self.position == 0: return 0 elif self.position == -1: return self.duration for chapter in self.chapters: if chapter.id == self.position: relative_position = max( chapter.position - chapter.start_position, 0) progress += int(relative_position / 1000000000) return progress progress += chapter.length return progress def remove(self): if self._settings.last_played_book and self._settings.last_played_book.id == self._db_object.id: self._settings.last_played_book = None book_tracks = [ TrackModel.get_by_id(chapter.id) for chapter in self.chapters ] track_to_files = TrackToFile.select().join(TrackModel).where( TrackToFile.track << book_tracks) for track in track_to_files: try: track.file.delete_instance(recursive=True) except DoesNotExist: track.delete_instance() for track in book_tracks: track.delete_instance(recursive=True) self._db_object.delete_instance(recursive=True) self.destroy_listeners() self._destroy_observers() def _fetch_chapters(self): tracks = TrackModel \ .select(TrackModel.id) \ .where(TrackModel.book == self._db_object) \ .order_by(TrackModel.disk, TrackModel.number, TrackModel.name) self._chapters = [] for track in tracks: try: track_model = Track(self._db, track.id) self._chapters.append(track_model) except TrackInconsistentData: log.warning("Skipping inconsistent model") except Exception as e: log.error("Could not create chapter object: {}".format(e)) for chapter in self._chapters: chapter.add_listener(self._on_chapter_event) def _on_chapter_event(self, event: str, chapter: Chapter): if event == "chapter-deleted": try: self.chapters.remove(chapter) except ValueError: pass if len(self._chapters) < 1: if self._settings.last_played_book and self._settings.last_played_book.id == self._db_object.id: self._settings.last_played_book = None self._db_object.delete_instance(recursive=True) self.emit_event("book-deleted", self) self.destroy_listeners() self._destroy_observers()
class FilesystemMonitor(EventSender): external_storage: List[ExternalStorage] = [] _settings: Settings = inject.attr(Settings) def __init__(self): super().__init__() self.volume_monitor: Gio.VolumeMonitor = Gio.VolumeMonitor.get() self.volume_monitor.connect("mount-added", self.__on_mount_added) self.volume_monitor.connect("mount-removed", self.__on_mount_removed) self.init_offline_mode() from cozy.ui.settings import Settings as UISettings self._ui_settings = inject.instance(UISettings) self._ui_settings.add_listener(self.__on_settings_changed) def init_offline_mode(self): external_storage = [] mounts = self.volume_monitor.get_mounts() # go through all audiobook locations and test if they can be found in the mounts list for storage in self._settings.external_storage_locations: online = False if any(mount.get_root().get_path() in storage.path for mount in mounts): online = True self.external_storage.append( ExternalStorage(storage=storage, online=online)) def close(self): """ Free all references. """ # self.volume_monitor.unref() pass def get_book_online(self, book: Book): result = next((storage.online for storage in self.external_storage if storage.storage.path in book.chapters[0].file), True) return result def is_track_online(self, track): """ """ result = next((storage.online for storage in self.external_storage if storage.storage.path in track.file), True) return (result) def get_offline_storages(self): return [i.storage.path for i in self.external_storage if not i.online] def is_storage_online(self, storage: Storage) -> bool: storage = next((storage for storage in self.external_storage if storage.storage == storage), None) if not storage: raise StorageNotFound return storage.online def is_external(self, directory: str) -> bool: mounts: List[Gio.Mount] = self.volume_monitor.get_mounts() for mount in mounts: root = mount.get_root() if not root: log.error( "Failed to test for external drive. Mountpoint has no root object." ) reporter.error( "fs_monitor", "Failed to test for external drive. Mountpoint has no root object." ) return False path = root.get_path() if not path: log.error( "Failed to test for external drive. Root object has no path." ) reporter.error( "fs_monitor", "Failed to test for external drive. Root object has no path." ) return False if path in directory and mount.can_unmount(): return True return False def __on_mount_added(self, monitor, mount): """ A volume was mounted. Disable offline mode for this volume. """ mount_path = mount.get_root().get_path() if not mount_path: log.warning( "Mount added but no mount_path is present. Skipping...") return log.debug("Volume mounted: " + mount_path) storage = next( (s for s in self.external_storage if mount_path in s.storage.path), None) if storage: log.info("Storage online: " + mount_path) storage.online = True self.emit_event("storage-online", storage.storage.path) def __on_mount_removed(self, monitor, mount): """ A volume was unmounted. Enable offline mode for this volume. """ mount_path = mount.get_root().get_path() if not mount_path: log.warning( "Mount removed but no mount_path is present. Skipping...") return log.debug("Volume unmounted: " + mount_path) storage = next( (s for s in self.external_storage if mount_path in s.storage.path), None) if storage: log.info("Storage offline: " + mount_path) storage.online = False self.emit_event("storage-offline", storage.storage.path) # switch to offline version if currently playing def __on_settings_changed(self, event, message): """ This method reacts to storage settings changes. """ if event == "external-storage-added" or event == "storage-changed" or ( event == "storage-added" and message != ""): self.init_offline_mode() elif event == "storage-removed" or event == "external-storage-removed": self.external_storage = [ item for item in self.external_storage if item.storage.path not in message ]
class Files(EventSender): _settings = inject.attr(Settings) _importer = inject.attr(Importer) _file_count = 0 _file_progess = 0 def __init__(self): super().__init__() def copy(self, selection): log.info("Start of copying files") self.emit_event_main_thread("start-copy", None) uris = selection.get_uris() storage_location = self._settings.default_location.path self._file_count = 0 self._file_progess = 0 self._count_all_files(uris) self._copy_all(uris, storage_location) log.info("Copying of files finished") self._importer.scan() def _copy_all(self, sources, destination: str): for uri in sources: parsed_path = urllib.parse.urlparse(uri) path = urllib.parse.unquote(parsed_path.path) if os.path.isdir(path): self._copy_directory(path, destination) else: filename = os.path.basename(path) file_copy_destination = os.path.join(destination, filename) self._copy_file(path, file_copy_destination) def _copy_file(self, source_path: str, dest_path: str): log.info("Copy file {} to {}".format(source_path, dest_path)) source = Gio.File.new_for_path(source_path) destination = Gio.File.new_for_path(dest_path) flags = Gio.FileCopyFlags.OVERWRITE self.filecopy_cancel = Gio.Cancellable() try: copied = source.copy(destination, flags, self.filecopy_cancel, self._update_copy_status, None) except Exception as e: if e.code == Gio.IOErrorEnum.CANCELLED: pass reporter.exception("files", e) log.error("Failed to copy file: {}".format(e)) self._file_progess += 1 def _copy_directory(self, path, destination): main_source_path = os.path.split(path)[0] for dirpath, dirnames, filenames in os.walk(path): dirname = os.path.relpath(dirpath, main_source_path) destination_dir = os.path.join(destination, dirname) Path(destination_dir).mkdir(parents=True, exist_ok=True) for file in filenames: source = os.path.join(dirpath, file) file_copy_destination = os.path.join(destination, dirname, file) self._copy_file(source, file_copy_destination) def _count_all_files(self, uris): for uri in uris: parsed_path = urllib.parse.urlparse(uri) path = urllib.parse.unquote(parsed_path.path) if os.path.isdir(path): self._file_count += self._count_files_in_folder(path) else: self._file_count += 1 def _update_copy_status(self, current_num_bytes, total_num_bytes, _): if total_num_bytes == 0: total_num_bytes = 1 if self._file_count == 0: progress = 1.0 else: progress = ((self._file_progess - 1) / self._file_count) + ( (current_num_bytes / total_num_bytes) / self._file_count) self.emit_event_main_thread("copy-progress", progress) def _count_files_in_folder(self, path: str) -> int: return sum([len(files) for r, d, files in os.walk(path)])
class AlbumElement(Gtk.Box): """ This class represents a clickable album art widget for a book. """ artwork_cache: ArtworkCache = inject.attr(ArtworkCache) def __init__(self, book, size, scale, bordered=False, square=False): """ :param size: the size for the longer side of the image :param bordered: should there be a border around the album art? :param square: should the widget be always a square? """ super().__init__() self.props.height_request = size self.book: Book = book self.selected = False self.signal_ids = [] self.play_signal_ids = [] # the event box is used for mouse enter and leave signals self.event_box = Gtk.EventBox() self.event_box.set_property("halign", Gtk.Align.CENTER) self.event_box.set_property("valign", Gtk.Align.CENTER) # scale the book cover to a fix size. pixbuf = self.artwork_cache.get_cover_pixbuf(book.db_object, scale, size) # box is the main container for the album art self.set_halign(Gtk.Align.CENTER) self.set_valign(Gtk.Align.CENTER) # img contains the album art img = Gtk.Image() img.set_halign(Gtk.Align.CENTER) img.set_valign(Gtk.Align.CENTER) if pixbuf: if bordered: img.get_style_context().add_class("bordered") surface = Gdk.cairo_surface_create_from_pixbuf(pixbuf, scale, None) img.set_from_surface(surface) else: img.set_from_icon_name("book-open-variant-symbolic", Gtk.IconSize.DIALOG) img.props.pixel_size = size self.play_box = Gtk.EventBox() # on click we want to play the audio book self.play_signal_ids.append( self.play_box.connect("button-press-event", self._on_play_button_press)) self.play_box.set_property("halign", Gtk.Align.CENTER) self.play_box.set_property("valign", Gtk.Align.CENTER) self.play_box.set_tooltip_text(_("Play this book")) # play_color is an overlay for the play button # with this it should be visible on any album art color play_image = GdkPixbuf.Pixbuf.new_from_resource( "/com/github/geigi/cozy/play_background.svg") if square: play_image = play_image.scale_simple(size - 10, size - 10, GdkPixbuf.InterpType.BILINEAR) if size < 100: self.icon_size = Gtk.IconSize.LARGE_TOOLBAR else: self.icon_size = Gtk.IconSize.DIALOG self.play_button = Gtk.Image.new_from_icon_name( "media-playback-start-symbolic", self.icon_size) self.play_button.set_property("halign", Gtk.Align.CENTER) self.play_button.set_property("valign", Gtk.Align.CENTER) self.play_button.get_style_context().add_class("white") # this is the main overlay for the album art # we need to create field for the overlays # to change the opacity of them on mouse over/leave events self.overlay = Gtk.Overlay.new() # this is the play symbol overlay self.play_overlay = Gtk.Overlay.new() # this is for the play button animation self.play_revealer = Gtk.Revealer() self.play_revealer.set_transition_type( Gtk.RevealerTransitionType.CROSSFADE) self.play_revealer.set_transition_duration(300) self.play_revealer.add(self.play_overlay) self.play_revealer.add_events(Gdk.EventMask.ENTER_NOTIFY_MASK) self.play_revealer.add_events(Gdk.EventMask.LEAVE_NOTIFY_MASK) # this grid has a background color to act as a visible overlay color = Gtk.Grid() color.set_property("halign", Gtk.Align.CENTER) color.set_property("valign", Gtk.Align.CENTER) if square: self.set_size_request(size, size) smaller_width = size - pixbuf.get_width() if smaller_width > 1: self.event_box.set_margin_left(smaller_width / 2) # assemble play overlay self.play_box.add(self.play_button) self.play_overlay.add(self.play_box) # assemble overlay with album art self.overlay.add(img) self.overlay.add_overlay(self.play_revealer) # assemble overlay color color.add(self.overlay) self.event_box.add(color) self.add(self.event_box) # connect signals self.play_signal_ids.append( self.play_box.connect("enter-notify-event", self._on_play_enter_notify)) self.play_signal_ids.append( self.play_box.connect("leave-notify-event", self._on_play_leave_notify)) # connect mouse events to the event box self.signal_ids.append( self.event_box.connect("enter-notify-event", self._on_enter_notify)) self.signal_ids.append( self.event_box.connect("leave-notify-event", self._on_leave_notify)) def disconnect_signals(self): """ Disconnect all signals from this element. """ [self.event_box.disconnect(sig) for sig in self.signal_ids] [self.play_box.disconnect(sig) for sig in self.play_signal_ids] def _on_enter_notify(self, widget, event): """ On enter notify change overlay opacity :param widget: as Gtk.EventBox :param event: as Gdk.Event """ self.play_revealer.set_reveal_child(True) def _on_leave_notify(self, widget, event): """ On leave notify change overlay opacity :param widget: as Gtk.EventBox (can be None) :param event: as Gdk.Event (can be None) """ if not self.selected: self.play_revealer.set_reveal_child(False) def _on_play_enter_notify(self, widget, event): """ Change the cursor to pointing hand """ self.props.window.set_cursor( Gdk.Cursor.new_from_name(self.get_display(), "pointer")) def _on_play_leave_notify(self, widget, event): """ Reset the cursor. """ self.props.window.set_cursor(None) def _on_play_button_press(self, widget, event): """ Play this book. """ if event.type == Gdk.EventType.BUTTON_PRESS and event.button != 1: return False self.emit("play-pause-clicked", self.book.db_object) track = get_track_for_playback(self.book.db_object) current_track = player.get_current_track() if current_track and current_track.book.id == self.book.db_object.id: player.play_pause(None) if player.get_gst_player_state() == Gst.State.PLAYING: player.jump_to_ns(track.position) else: player.load_file(track) player.play_pause(None, True) return True
class SleepTimer(Gtk.Popover): __gtype_name__ = "SleepTimer" _view_model = inject.attr(SleepTimerViewModel) timer_scale: Gtk.Scale = Gtk.Template.Child() timer_label: Gtk.Label = Gtk.Template.Child() timer_grid: Gtk.Grid = Gtk.Template.Child() min_label: Gtk.Label = Gtk.Template.Child() chapter_switch: Gtk.Switch = Gtk.Template.Child() power_control_switch: Gtk.Switch = Gtk.Template.Child() power_control_options: Gtk.Box = Gtk.Template.Child() system_shutdown_radiob: Gtk.RadioButton = Gtk.Template.Child() system_suspend_radiob: Gtk.RadioButton = Gtk.Template.Child() def __init__(self, timer_image: Gtk.Image): super().__init__() self._timer_image: Gtk.Image = timer_image self._init_timer_scale() self._connect_widgets() self._connect_view_model() self._on_timer_scale_changed(self.timer_scale) def _connect_widgets(self): self.timer_scale.connect("value-changed", self._on_timer_scale_changed) self.chapter_switch.connect("state-set", self._on_chapter_switch_changed) self.power_control_switch.connect( "state-set", self._on_power_options_switch_changed) self.system_suspend_radiob.connect( "toggled", self._on_system_action_radio_button_changed) self.system_shutdown_radiob.connect( "toggled", self._on_system_action_radio_button_changed) def _connect_view_model(self): self._view_model.bind_to("stop_after_chapter", self._on_stop_after_chapter_changed) self._view_model.bind_to("remaining_seconds", self._on_remaining_seconds_changed) self._view_model.bind_to("timer_enabled", self._on_timer_enabled_changed) def _init_timer_scale(self): for i in range(0, 121, 30): self.timer_scale.add_mark(i, Gtk.PositionType.RIGHT, None) def _on_timer_scale_changed(self, scale: Gtk.Scale): value = scale.get_value() if value > 0: self.timer_label.set_visible(True) self.min_label.set_text(_("min")) text = str(int(value)) self.timer_label.set_text(text) self._view_model.remaining_seconds = value * 60 else: self.min_label.set_text(_("Off")) self.timer_label.set_visible(False) self._view_model.remaining_seconds = 0 def _on_chapter_switch_changed(self, _, state): self.timer_grid.set_sensitive(not state) self._view_model.stop_after_chapter = state def _on_remaining_seconds_changed(self): if self._view_model.remaining_seconds < 1: value = 0 else: value = int((self._view_model.remaining_seconds / 60)) + 1 self.timer_scale.set_value(value) def _on_power_options_switch_changed(self, _, state): self.power_control_options.set_sensitive(state) if not state: self._view_model.system_power_control = SystemPowerControl.OFF else: self._on_system_action_radio_button_changed(None) def _on_system_action_radio_button_changed(self, _): if self.system_suspend_radiob.get_active(): self._view_model.system_power_control = SystemPowerControl.SUSPEND else: self._view_model.system_power_control = SystemPowerControl.SHUTDOWN def _on_stop_after_chapter_changed(self): self.chapter_switch.set_active(self._view_model.stop_after_chapter) def _on_timer_enabled_changed(self): if self._view_model.timer_enabled: icon = "timer-on-symbolic" else: icon = "timer-off-symbolic" self._timer_image.set_from_icon_name(icon, Gtk.IconSize.BUTTON)
class LibraryViewModel(Observable, EventSender): _application_settings: ApplicationSettings = inject.attr(ApplicationSettings) _fs_monitor: FilesystemMonitor = inject.attr("FilesystemMonitor") _model = inject.attr(Library) _importer: Importer = inject.attr(Importer) _player: Player = inject.attr(Player) def __init__(self): super().__init__() super(Observable, self).__init__() self._library_view_mode: LibraryViewMode = LibraryViewMode.CURRENT self._selected_filter: str = _("All") self._connect() def _connect(self): self._fs_monitor.add_listener(self._on_fs_monitor_event) self._application_settings.add_listener(self._on_application_setting_changed) self._importer.add_listener(self._on_importer_event) self._player.add_listener(self._on_player_event) self._model.add_listener(self._on_model_event) @property def books(self): return self._model.books @property def library_view_mode(self): return self._library_view_mode @library_view_mode.setter def library_view_mode(self, value): self._library_view_mode = value self._notify("library_view_mode") @property def selected_filter(self): return self._selected_filter @selected_filter.setter def selected_filter(self, value): self._selected_filter = value self._notify("selected_filter") @property def is_any_book_in_progress(self): return any(book.position > 0 for book in self.books) @property def authors(self): is_book_online = self._fs_monitor.get_book_online show_offline_books = not self._application_settings.hide_offline swap_author_reader = self._application_settings.swap_author_reader authors = { book.author if not swap_author_reader else book.reader for book in self._model.books if is_book_online(book) or show_offline_books or book.downloaded } return sorted(split_strings_to_set(authors)) @property def readers(self): is_book_online = self._fs_monitor.get_book_online show_offline_books = not self._application_settings.hide_offline swap_author_reader = self._application_settings.swap_author_reader readers = { book.reader if not swap_author_reader else book.author for book in self._model.books if is_book_online(book) or show_offline_books or book.downloaded } return sorted(split_strings_to_set(readers)) def playback_book(self, book: Book): # Pause/Play book here pass def remove_book(self, book: Book): book.remove() self._model.invalidate() self._notify("authors") self._notify("readers") self._notify("books") self._notify("books-filter") def display_book_filter(self, book_element: BookElement): book = book_element.book swap_author_reader = self._application_settings.swap_author_reader author = book.author if not swap_author_reader else book.reader reader = book.reader if not swap_author_reader else book.author hide_offline_books = self._application_settings.hide_offline book_is_online = self._fs_monitor.get_book_online(book) if hide_offline_books and not book_is_online and not book.downloaded: return False if self.library_view_mode == LibraryViewMode.CURRENT: return True if book.last_played > 0 else False if self.selected_filter == _("All"): return True elif self.library_view_mode == LibraryViewMode.AUTHOR: return True if self.selected_filter in author else False elif self.library_view_mode == LibraryViewMode.READER: return True if self.selected_filter in reader else False def display_book_sort(self, book_element1, book_element2): if self._library_view_mode == LibraryViewMode.CURRENT: return book_element1.book.last_played < book_element2.book.last_played else: return book_element1.book.name.lower() > book_element2.book.name.lower() def _on_fs_monitor_event(self, event, _): if event == "storage-online": self._notify("authors") self._notify("readers") self._notify("books-filter") elif event == "storage-offline": self._notify("authors") self._notify("readers") self._notify("books-filter") elif event == "external-storage-added": pass elif event == "external-storage-removed": pass def _on_application_setting_changed(self, event, _): if event == "hide-offline": self._notify("authors") self._notify("readers") self._notify("books-filter") elif event == "swap-author-reader": self._notify("authors") self._notify("readers") def _on_importer_event(self, event, message): if event == "scan" and message == ScanStatus.SUCCESS: self._notify("authors") self._notify("readers") self._notify("books") self._notify("books-filter") self._notify("library_view_mode") if event == "import-failed": dialog = ImportFailedDialog(message) dialog.show() def _on_player_event(self, event, message): if event == "play": track_id = message book = None for b in self._model.books: if any(chapter.id == track_id for chapter in b.chapters): book = b break if book: book.reload() self._notify("books-filter") def _on_model_event(self, event: str, message): if event == "rebase-finished": self.emit_event("work-done")
class SearchView: view_model = inject.attr(SearchViewModel) search_thread = None search_thread_stop = None def __init__(self, main_window_builder: Gtk.Builder): self.builder = Gtk.Builder.new_from_resource( "/com/github/geigi/cozy/search_popover.ui") self.main_window_builder: Gtk.Builder = main_window_builder self.popover = self.builder.get_object("search_popover") self.search_button = self.main_window_builder.get_object( "search_button") self.book_label = self.builder.get_object("book_label") self.track_label = self.builder.get_object("track_label") self.author_label = self.builder.get_object("author_label") self.reader_label = self.builder.get_object("reader_label") self.reader_box = self.builder.get_object("reader_result_box") self.author_box = self.builder.get_object("author_result_box") self.book_box = self.builder.get_object("book_result_box") self.track_box = self.builder.get_object("track_result_box") self.entry = self.builder.get_object("search_entry") self.scroller = self.builder.get_object("search_scroller") self.book_separator = self.builder.get_object("book_separator") self.author_separator = self.builder.get_object("author_separator") self.reader_separator = self.builder.get_object("reader_separator") self.stack = self.builder.get_object("search_stack") self.search_button.set_popover(self.popover) self.entry.connect("search-changed", self.__on_search_changed) if Gtk.get_minor_version() > 20: self.scroller.set_max_content_width(400) self.scroller.set_max_content_height(600) self.scroller.set_propagate_natural_height(True) self.scroller.set_propagate_natural_width(True) self.search_thread = Thread(target=self.search, name="SearchThread") self.search_thread_stop = threading.Event() self._connect_view_model() def _connect_view_model(self): self.view_model.bind_to("search_open", self._on_search_open_changed) def search(self, user_search: str): # we need the main context to call methods in the main thread after the search is finished main_context = GLib.MainContext.default() books = list({ book for book in self.view_model.books if user_search.lower() in book.name.lower() or user_search.lower() in book.author.lower() or user_search.lower() in book.reader.lower() }) books = sorted(books, key=lambda book: book.name.lower()) if self.search_thread_stop.is_set(): return main_context.invoke_full(GLib.PRIORITY_DEFAULT, self.__on_book_search_finished, books) authors = sorted({ author for author in self.view_model.authors if user_search.lower() in author.lower() }) if self.search_thread_stop.is_set(): return main_context.invoke_full(GLib.PRIORITY_DEFAULT, self.__on_author_search_finished, authors) readers = sorted({ reader for reader in self.view_model.readers if user_search.lower() in reader.lower() }) if self.search_thread_stop.is_set(): return main_context.invoke_full(GLib.PRIORITY_DEFAULT, self.__on_reader_search_finished, readers) if len(readers) < 1 and len(authors) < 1 and len(books) < 1: main_context.invoke_full(GLib.PRIORITY_DEFAULT, self.stack.set_visible_child_name, "nothing") def close(self, object=None): if Gtk.get_minor_version() < 22: self.popover.hide() else: self.popover.popdown() def __on_search_changed(self, sender): self.search_thread_stop.set() # we want to avoid flickering of the search box size # as we remove widgets and add them again # so we get the current search popup size and set it as # the preferred size until the search is finished # this helps only a bit, the widgets are still flickering self.popover.set_size_request(self.popover.get_allocated_width(), self.popover.get_allocated_height()) # hide nothing found self.stack.set_visible_child_name("main") # First clear the boxes self.book_box.remove_all_children() self.author_box.remove_all_children() self.reader_box.remove_all_children() # Hide all the labels & separators self.book_label.set_visible(False) self.author_label.set_visible(False) self.reader_label.set_visible(False) self.book_separator.set_visible(False) self.author_separator.set_visible(False) self.reader_separator.set_visible(False) self.track_label.set_visible(False) user_search = self.entry.get_text() if user_search: if self.search_thread.is_alive(): self.search_thread.join(timeout=0.2) self.search_thread_stop.clear() self.search_thread = Thread(target=self.search, args=(user_search, )) self.search_thread.start() else: self.stack.set_visible_child_name("start") self.popover.set_size_request(-1, -1) def _on_search_open_changed(self): if self.view_model.search_open == False: self.close() def __on_book_search_finished(self, books): if len(books) > 0: self.stack.set_visible_child_name("main") self.book_label.set_visible(True) self.book_separator.set_visible(True) for book in books: if self.search_thread_stop.is_set(): return book_result = BookSearchResult(book, self.view_model.jump_to_book) self.book_box.add(book_result) def __on_author_search_finished(self, authors): if len(authors) > 0: self.stack.set_visible_child_name("main") self.author_label.set_visible(True) self.author_separator.set_visible(True) for author in authors: if self.search_thread_stop.is_set(): return author_result = ArtistSearchResult( self.view_model.jump_to_author, author, True) self.author_box.add(author_result) def __on_reader_search_finished(self, readers): if len(readers) > 0: self.stack.set_visible_child_name("main") self.reader_label.set_visible(True) self.reader_separator.set_visible(True) for reader in readers: if self.search_thread_stop.is_set(): return reader_result = ArtistSearchResult( self.view_model.jump_to_reader, reader, False) self.reader_box.add(reader_result) self.popover.set_size_request(-1, -1)
class DatabaseImporter: _db = inject.attr(SqliteDatabase) def __init__(self): self._book_update_positions: List[BookUpdatePositionRequest] = [] def insert_many(self, media_files: Set[MediaFile]): self._book_update_positions = [] files = self._prepare_files_db_objects(media_files) File.insert_many(files).execute() tracks = self._prepare_track_db_objects(media_files) self._insert_tracks(tracks) self._update_book_positions() def _prepare_files_db_objects(self, media_files: Set[MediaFile]) -> List[object]: files = [] for media_file in media_files: query = File.select().where(File.path == media_file.path) if query.exists(): self._update_files_in_db(query.get(), media_file) continue file_already_in_list = any(f["path"] == media_file.path for f in files) if not file_already_in_list: files.append({ "path": media_file.path, "modified": media_file.modified }) return files def _update_files_in_db(self, file: File, media_file: MediaFile): file.modified = media_file.modified file.save(only=file.dirty_fields) def _prepare_track_db_objects( self, media_files: Set[MediaFile]) -> Set[TrackInsertRequest]: book_db_objects: Set[BookModel] = set() for media_file in media_files: if not media_file: continue book = next((book for book in book_db_objects if is_same_book(book.name, media_file.book_name)), None) file_query = File.select().where(File.path == media_file.path) if not file_query.exists(): log.error("No file object with path present: {}".format( media_file.path)) continue file = file_query.get() if not book: book = self._import_or_update_book(media_file) book_db_objects.add(book) try: book_model = Book(self._db, book.id) progress = book_model.progress except BookIsEmpty: progress = 0 self._delete_tracks_from_db(media_file) tracks = self._get_track_list_for_db(media_file, book) for track in tracks: start_at = track.pop("startAt") yield TrackInsertRequest(track, file, start_at) update_position_request_present = any( b.book_id == book.id for b in self._book_update_positions) if progress > 0 and not update_position_request_present: self._book_update_positions.append( BookUpdatePositionRequest(book.id, progress)) def _import_or_update_book(self, media_file): if BookModel.select(BookModel.name).where( self._matches_db_book(media_file.book_name)).count() < 1: book = self._create_book_db_object(media_file) else: book = self._update_book_db_object(media_file) return book def _matches_db_book(self, book_name: str) -> bool: return fn.Lower(BookModel.name) == book_name.lower() def _get_track_list_for_db(self, media_file: MediaFile, book: BookModel): tracks = [] for chapter in media_file.chapters: tracks.append({ "name": chapter.name, "number": chapter.number, "disk": media_file.disk, "book": book, "length": chapter.length, "startAt": chapter.position, "position": 0 }) return tracks def _update_book_db_object(self, media_file: MediaFile) -> BookModel: BookModel.update(name=media_file.book_name, author=media_file.author, reader=media_file.reader, cover=media_file.cover) \ .where(self._matches_db_book(media_file.book_name)) \ .execute() return BookModel.select().where( self._matches_db_book(media_file.book_name)).get() def _create_book_db_object(self, media_file: MediaFile) -> BookModel: return BookModel.create(name=media_file.book_name, author=media_file.author, reader=media_file.reader, cover=media_file.cover, position=0, rating=-1) def _get_track_db_objects_for_media_file( self, media_file: MediaFile) -> List[Track]: all_track_mappings = TrackToFile.select().join(File).where( TrackToFile.file.path == media_file.path) for item in all_track_mappings: yield item.track def _delete_tracks_from_db(self, media_file: MediaFile): for track in self._get_track_db_objects_for_media_file(media_file): track.delete_instance(recursive=True) def _is_chapter_count_in_db_different(self, media_file: MediaFile) -> bool: all_track_mappings = self._get_chapter_count_in_db(media_file) if all_track_mappings != len(media_file.chapters): return True else: return False def _get_chapter_count_in_db(self, media_file: MediaFile) -> int: all_track_mappings = TrackToFile.select().join(File).where( TrackToFile.file.path == media_file.path) return all_track_mappings.count() def _insert_tracks(self, tracks: Set[TrackInsertRequest]): for track in tracks: track_db = Track.insert(track.track_data).execute() TrackToFile.create(track=track_db, file=track.file, start_at=track.start_at) def _update_book_positions(self): for book_position in self._book_update_positions: book = BookModel.get_or_none(book_position.book_id) if not book: log.error( "Could not restore book position because book is not present" ) continue self._update_book_position(book, book_position.progress) self._book_update_positions = [] def _update_book_position(self, book: BookModel, progress: int): try: book_model = Book(self._db, book.id) except BookIsEmpty: log.error("Could not restore book position because book is empty") return completed_chapter_length = 0 for chapter in book_model.chapters: old_position = progress if completed_chapter_length + chapter.length > old_position: chapter.position = chapter.start_position + ( (old_position - completed_chapter_length) * 10**9) book_model.position = chapter.id return else: completed_chapter_length += chapter.length book_model.position = 0
class BookDetailViewModel(Observable, EventSender): _player: Player = inject.attr(Player) _fs_monitor: FilesystemMonitor = inject.attr("FilesystemMonitor") _offline_cache: OfflineCache = inject.attr(OfflineCache) _settings: Settings = inject.attr(Settings) _library = Library = inject.attr(Library) def __init__(self): super().__init__() super(Observable, self).__init__() self._play = False self._current_chapter = None self._book: Book = None self._lock_ui: bool = False self._player.add_listener(self._on_player_event) self._fs_monitor.add_listener(self._on_fs_monitor_event) self._offline_cache.add_listener(self._on_offline_cache_event) @property def playing(self) -> bool: if not self._player.loaded_book or self._player.loaded_book != self._book: return False return self._player.playing @property def current_chapter(self) -> Optional[Chapter]: if not self.book: return None return self.book.current_chapter @property def book(self) -> Optional[Book]: return self._book @book.setter def book(self, value: Book): if self._book: self._book.remove_bind("current_chapter", self._on_book_current_chapter_changed) self._book.remove_bind("last_played", self._on_book_last_played_changed) self._book.remove_bind("duration", self._on_book_duration_changed) self._book.remove_bind("progress", self._on_book_progress_changed) self._book.remove_bind("playback_speed", self._on_playback_speed_changed) self._book = value self._current_chapter = None self._book.bind_to("current_chapter", self._on_book_current_chapter_changed) self._book.bind_to("last_played", self._on_book_last_played_changed) self._book.bind_to("duration", self._on_book_duration_changed) self._book.bind_to("progress", self._on_book_progress_changed) self._book.bind_to("playback_speed", self._on_playback_speed_changed) self._notify("book") @property def last_played_text(self) -> Optional[str]: if not self._book: return None return tools.past_date_to_human_readable(self._book.last_played) @property def total_text(self) -> Optional[str]: if not self._book: return None return tools.seconds_to_human_readable(self._book.duration / self._book.playback_speed) @property def remaining_text(self) -> Optional[str]: if not self._book: return None remaining = self._book.duration / self._book.playback_speed - self._book.progress / self._book.playback_speed return tools.seconds_to_human_readable(remaining) @property def progress_percent(self) -> Optional[float]: if not self._book: return None if self._book.duration < 1: return 1.0 return self._book.progress / self._book.duration @property def disk_count(self) -> int: if not self._book: return 0 return len({chapter.disk for chapter in self._book.chapters}) @property def is_book_available(self) -> bool: return self._fs_monitor.get_book_online(self._book) @property def is_book_external(self) -> bool: first_chapter_path = self._book.chapters[0].file return any(storage.path in first_chapter_path for storage in self._settings.external_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 download_book(self, download: bool): self._book.offline = download if download: self._offline_cache.add(self._book.db_object) else: self._offline_cache.remove(self._book.db_object) def open_library(self): self.emit_event(OpenView.LIBRARY) def play_book(self): self._player.play_pause_book(self.book) def play_chapter(self, chapter: Chapter): self._player.play_pause_chapter(self._book, chapter) def _on_player_event(self, event, message): if not self.book: return if event == "play" or event == "pause": self._notify("playing") elif event == "position": self._notify("progress_percent") self._notify("remaining_text") def _on_fs_monitor_event(self, event, _): if event == "storage-online": self._notify("is_book_available") elif event == "storage-offline": self._notify("is_book_available") def _on_book_current_chapter_changed(self): self._notify("current_chapter") def _on_book_last_played_changed(self): self._notify("last_played_text") def _on_book_progress_changed(self): self._notify("remaining_text") self._notify("progress_percent") def _on_book_duration_changed(self): self._notify("progress_percent") self._notify("remaining_text") self._notify("total_text") def _on_playback_speed_changed(self): self._notify("progress_percent") self._notify("remaining_text") self._notify("total_text") def _on_offline_cache_event(self, event, message): try: if message.id != self._book.db_object.id: return except Exception as e: return if event == "book-offline-removed": # TODO: Remove this when offline cache refactoring is complete self._book.downloaded = False self._notify("downloaded") elif event == "book-offline": self._book.downloaded = True self._notify("downloaded")
class CozyUI(EventSender, metaclass=Singleton): """ CozyUI is the main ui class. """ # Is currently an dialog open? dialog_open = False is_initialized = False __inhibit_cookie = None fs_monitor = inject.attr(fs_monitor.FilesystemMonitor) offline_cache = inject.attr(OfflineCache) settings = inject.attr(Settings) application_settings = inject.attr(ApplicationSettings) _importer: Importer = inject.attr(Importer) _settings: SettingsModel = inject.attr(SettingsModel) _files: Files = inject.attr(Files) _player: Player = inject.attr(Player) def __init__(self, pkgdatadir, app, version): super().__init__() self.pkgdir = pkgdatadir self.app = app self.version = version self._library_view: LibraryView = None def activate(self, library_view: LibraryView): self.first_play = True self.__init_actions() self.__init_components() self._library_view = library_view self.auto_import() self.refresh_content() self.check_for_tracks() self.is_initialized = True def startup(self): self.__init_resources() self.__init_css() self.__init_window() def __init_resources(self): """ Initialize all resources like gresource and glade windows. """ resource = Gio.resource_load( os.path.join(self.pkgdir, 'com.github.geigi.cozy.ui.gresource')) Gio.Resource._register(resource) resource = Gio.resource_load( os.path.join(self.pkgdir, 'com.github.geigi.cozy.img.gresource')) Gio.Resource._register(resource) self.window_builder = Gtk.Builder.new_from_resource( "/com/github/geigi/cozy/main_window.ui") self.about_builder = Gtk.Builder.new_from_resource( "/com/github/geigi/cozy/about.ui") def __init_css(self): """ Initialize the main css files and providers. Add css classes to the default screen style context. """ main_cssProviderFile = Gio.File.new_for_uri( "resource:///com/github/geigi/cozy/application.css") main_cssProvider = Gtk.CssProvider() main_cssProvider.load_from_file(main_cssProviderFile) if Gtk.get_minor_version() > 18: log.debug("Fanciest design possible") cssProviderFile = Gio.File.new_for_uri( "resource:///com/github/geigi/cozy/application_default.css") else: log.debug("Using legacy css file") cssProviderFile = Gio.File.new_for_uri( "resource:///com/github/geigi/cozy/application_legacy.css") cssProvider = Gtk.CssProvider() cssProvider.load_from_file(cssProviderFile) # add the bordered css class to the default screen for the borders around album art screen = Gdk.Screen.get_default() styleContext = Gtk.StyleContext() styleContext.add_provider_for_screen( screen, main_cssProvider, Gtk.STYLE_PROVIDER_PRIORITY_USER) styleContext.add_provider_for_screen( screen, cssProvider, Gtk.STYLE_PROVIDER_PRIORITY_USER) styleContext.add_class("bordered") def __init_window(self): """ Add fields for all ui objects we need to access from code. Initialize everything we can't do from glade like events and other stuff. """ log.info("Initialize main window") self.window: Gtk.Window = self.window_builder.get_object("app_window") self.window.set_default_size(1100, 700) self.window.set_application(self.app) self.window.show_all() self.window.present() self.window.connect("delete-event", self.on_close) self.window.connect("drag_data_received", self.__on_drag_data_received) self.window.drag_dest_set(Gtk.DestDefaults.MOTION | Gtk.DestDefaults.HIGHLIGHT | Gtk.DestDefaults.DROP, [Gtk.TargetEntry.new("text/uri-list", 0, 80)], Gdk.DragAction.COPY) self.window.title = "Cozy" self.book_box = self.window_builder.get_object("book_box") self.sort_stack = self.window_builder.get_object("sort_stack") self.main_stack: Gtk.Stack = self.window_builder.get_object("main_stack") self.category_toolbar = self.window_builder.get_object( "category_toolbar") self.sort_stack_revealer = self.window_builder.get_object( "sort_stack_revealer") # This fixes a bug where otherwise expand is # somehow set to true internally # but is still showing false in the inspector self.sort_stack_revealer.props.expand = True self.sort_stack_revealer.props.expand = False self.sort_stack_switcher = self.window_builder.get_object( "sort_stack_switcher") self.no_media_file_chooser = self.window_builder.get_object( "no_media_file_chooser") self.no_media_file_chooser.connect( "file-set", self.__on_no_media_folder_changed) self.auto_scan_switch = self.window_builder.get_object( "auto_scan_switch") # some visual stuff self.category_toolbar_separator = self.window_builder.get_object("category_toolbar_separator") if tools.is_elementary(): self.category_toolbar.set_visible(False) # get about dialog self.about_dialog = self.about_builder.get_object("about_dialog") self.about_dialog.set_modal(self.window) self.about_dialog.connect("delete-event", self.hide_window) self.about_dialog.set_version(self.version) # shortcuts self.accel = Gtk.AccelGroup() try: about_close_button = self.about_builder.get_object( "button_box").get_children()[2] if about_close_button: about_close_button.connect( "clicked", self.__about_close_clicked) except Exception as e: log.info("Not connecting about close button.") def __init_actions(self): """ Init all app actions. """ self.accel = Gtk.AccelGroup() help_action = Gio.SimpleAction.new("help", None) help_action.connect("activate", self.help) self.app.add_action(help_action) about_action = Gio.SimpleAction.new("about", None) about_action.connect("activate", self.about) self.app.add_action(about_action) quit_action = Gio.SimpleAction.new("quit", None) quit_action.connect("activate", self.quit) self.app.add_action(quit_action) self.app.set_accels_for_action( "app.quit", ["<Control>q", "<Control>w"]) pref_action = Gio.SimpleAction.new("prefs", None) pref_action.connect("activate", self.show_prefs) self.app.add_action(pref_action) self.app.set_accels_for_action("app.prefs", ["<Control>comma"]) self.scan_action = Gio.SimpleAction.new("scan", None) self.scan_action.connect("activate", self.scan) self.app.add_action(self.scan_action) self.play_pause_action = Gio.SimpleAction.new("play_pause", None) self.play_pause_action.connect("activate", self.play_pause) self.app.add_action(self.play_pause_action) self.app.set_accels_for_action("app.play_pause", ["space"]) back_action = Gio.SimpleAction.new("back", None) back_action.connect("activate", self.back) self.app.add_action(back_action) self.app.set_accels_for_action("app.back", ["Escape"]) self.hide_offline_action = Gio.SimpleAction.new_stateful("hide_offline", None, GLib.Variant.new_boolean( self.application_settings.hide_offline)) self.hide_offline_action.connect("change-state", self.__on_hide_offline) self.app.add_action(self.hide_offline_action) def __init_components(self): if not self._player.loaded_book: self.block_ui_buttons(True) self._importer.add_listener(self._on_importer_event) def get_object(self, name): return self.window_builder.get_object(name) def help(self, action, parameter): """ Show app help. """ webbrowser.open("https://github.com/geigi/cozy/issues", new=2) def quit(self, action, parameter): """ Quit app. """ self.on_close(None) self.app.quit() def about(self, action, parameter): """ Show about window. """ self.about_dialog.show() def show_prefs(self, action, parameter): """ Show preferences window. """ self.settings.show() def hide_window(self, widget, data=None): """ Hide a given window. This is used for the about and settings dialog as they will never be closed only hidden. param widget: The widget that will be hidden. """ widget.hide() # we handeled the close event so the window must not get destroyed. return True def play_pause(self, action, parameter): self._player.play_pause() def block_ui_buttons(self, block, scan=False): """ Makes the buttons to interact with the player insensetive. :param block: Boolean """ sensitive = not block try: self.play_pause_action.set_enabled(sensitive) if scan: self.scan_action.set_enabled(sensitive) self.hide_offline_action.set_enabled(sensitive) self.settings.block_ui_elements(block) except: pass def switch_to_playing(self): """ Switch the UI state back to playing. This enables all UI functionality for the user. """ if self.main_stack.props.visible_child_name != "book_overview" and self.main_stack.props.visible_child_name != "nothing_here" and self.main_stack.props.visible_child_name != "no_media": self.main_stack.props.visible_child_name = "main" if self.main_stack.props.visible_child_name != "no_media" and self.main_stack.props.visible_child_name != "book_overview": self.category_toolbar.set_visible(True) if self._player.loaded_book: self.block_ui_buttons(False, True) else: # we want to only block the player controls self.block_ui_buttons(False, True) self.block_ui_buttons(True, False) self.window.props.window.set_cursor(None) self.emit_event_main_thread("working", False) def check_for_tracks(self): """ Check if there are any imported files. If there aren't display a welcome screen. """ if books().count() < 1: path = "" if len(self._settings.storage_locations) > 0: path = self._settings.default_location.path self.no_media_file_chooser.set_current_folder(path) self.main_stack.props.visible_child_name = "no_media" self.block_ui_buttons(True) self.category_toolbar.set_visible(False) def scan(self, _, __): thread = Thread(target=self._importer.scan, name="ScanMediaThread") thread.start() def auto_import(self): if self.application_settings.autoscan: self.scan(None, None) def back(self, action, parameter): self.emit_event("open_view", OpenView.LIBRARY) def refresh_content(self): """ Refresh all content. """ # First clear the boxes childs = self.book_box.get_children() for element in childs: self.book_box.remove(element) self._library_view.populate_author() self._library_view.populate_reader() self._library_view.populate_book_box() self.book_box.show_all() return False def display_failed_imports(self, files): """ Displays a dialog with a list of files that could not be imported. """ dialog = ImportFailedDialog(files) dialog.show() def __on_hide_offline(self, action, value): """ Show/Hide offline books action handler. """ action.set_state(value) self.application_settings.hide_offline = value.get_boolean() def __on_drag_data_received(self, widget, context, x, y, selection, target_type, timestamp): """ We want to import the files that are dragged onto the window. inspired by https://stackoverflow.com/questions/24094186/drag-and-drop-file-example-in-pygobject """ if target_type == 80: thread = Thread(target=self._files.copy, args=[selection], name="DragDropImportThread") thread.start() def __on_no_media_folder_changed(self, sender): """ Get's called when the user changes the audiobook location from the no media screen. Now we want to do a first scan instead of a rebase. """ location = self.no_media_file_chooser.get_file().get_path() external = self.fs_monitor.is_external(location) Storage.delete().where(Storage.path != "").execute() Storage.create(path=location, default=True, external=external) self._settings.invalidate() self.main_stack.props.visible_child_name = "import" self.scan(None, None) self.settings._init_storage() self.fs_monitor.init_offline_mode() def track_changed(self): self.block_ui_buttons(False, True) def __window_resized(self, window): """ Resize the progress scale to expand to the window size for older gtk versions. """ width, height = self.window.get_size() value = width - 850 if value < 80: value = 80 def __about_close_clicked(self, widget): self.about_dialog.hide() def on_close(self, widget, data=None): """ Close and dispose everything that needs to be when window is closed. """ log.info("Closing.") self.fs_monitor.close() self._player.destroy() close_db() report.close() log.info("Closing app.") self.app.quit() log.info("App closed.") def get_builder(self): return self.window_builder def _on_importer_event(self, event: str, message): if event == "scan" and message == ScanStatus.SUCCESS: self.check_for_tracks()