From e233056a84313f8bafbc719cfd11ce9f9ca02e4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benedek=20D=C3=A9v=C3=A9nyi?= Date: Sun, 14 Jul 2024 12:32:08 +0200 Subject: [PATCH 1/3] Rework playback architecture a bit, use nanoseconds everywhere (#928) It was kinda weird, that the setter for `Player.position` expected seconds, but the getter returned nanoseconds. Now every such interface should use nanoseconds, as that's what GStreamer uses internally, and we also don't have to care about floating point numbers. The database still uses seconds in some places though, but it's better this way, so no database migration is needed. Also some bug fixes --- cozy/app_controller.py | 3 +- cozy/control/mpris.py | 47 ++- cozy/control/string_representation.py | 28 -- cozy/control/time_format.py | 102 +++++ cozy/media/gst_player.py | 274 ------------- cozy/media/player.py | 386 +++++++++++++++--- cozy/media/tag_reader.py | 5 +- cozy/model/book.py | 2 +- cozy/model/database_importer.py | 2 +- cozy/model/track.py | 9 +- cozy/tools.py | 67 --- cozy/ui/chapter_element.py | 4 +- cozy/ui/widgets/seek_bar.py | 6 +- cozy/view_model/book_detail_view_model.py | 8 +- .../view_model/playback_control_view_model.py | 12 +- main.py | 1 + test/cozy/media/test_player.py | 2 +- test/cozy/model/test_book.py | 4 +- test/cozy/model/test_database_importer.py | 8 +- test/cozy/model/test_track.py | 6 +- 20 files changed, 488 insertions(+), 488 deletions(-) delete mode 100644 cozy/control/string_representation.py create mode 100644 cozy/control/time_format.py delete mode 100644 cozy/media/gst_player.py diff --git a/cozy/app_controller.py b/cozy/app_controller.py index a81193d5..f08f4c9b 100644 --- a/cozy/app_controller.py +++ b/cozy/app_controller.py @@ -8,8 +8,7 @@ from cozy.control.filesystem_monitor import FilesystemMonitor from cozy.control.offline_cache import OfflineCache from cozy.media.files import Files -from cozy.media.gst_player import GstPlayer -from cozy.media.player import Player +from cozy.media.player import GstPlayer, Player from cozy.model.book import Book from cozy.model.database_importer import DatabaseImporter from cozy.model.library import Library diff --git a/cozy/control/mpris.py b/cozy/control/mpris.py index fa4558cb..878110d4 100644 --- a/cozy/control/mpris.py +++ b/cozy/control/mpris.py @@ -17,7 +17,7 @@ from cozy.application_settings import ApplicationSettings from cozy.control.artwork_cache import ArtworkCache from cozy.ext import inject -from cozy.media.player import NS_TO_SEC, US_TO_SEC, Player +from cozy.media.player import Player from cozy.model.book import Book from cozy.report import reporter @@ -25,6 +25,8 @@ CamelCasePattern = re.compile(r"(? str: return CamelCasePattern.sub("_", name).lower() @@ -69,9 +71,7 @@ def __init__(self, connection: Gio.DBusConnection, path: str) -> None: for interface in Gio.DBusNodeInfo.new_for_xml(self.__doc__).interfaces: for method in interface.methods: - self.method_inargs[method.name] = tuple( - arg.signature for arg in method.in_args - ) + self.method_inargs[method.name] = tuple(arg.signature for arg in method.in_args) out_sig = [arg.signature for arg in method.out_args] self.method_outargs[method.name] = "(" + "".join(out_sig) + ")" @@ -111,10 +111,7 @@ def on_method_call( except Exception as e: log.error(e) reporter.exception("mpris", e) - reporter.error( - "mpris", - f"MPRIS method call failed with method name: {method_name}", - ) + reporter.error("mpris", f"MPRIS method call failed with method name: {method_name}") invocation.return_dbus_error( f"{interface_name}.Error.Failed", "Internal exception occurred" ) @@ -126,7 +123,7 @@ def on_method_call( result = (result,) out_args = self.method_outargs[method_name] - if out_args != "()" and result[0]: + if out_args != "()" and result[0] is not None: variant = GLib.Variant(out_args, result) invocation.return_value(variant) else: @@ -257,23 +254,17 @@ def stop(self): self._player.destroy() def set_position(self, track_id: str, position: int): - self._player.position = position / US_TO_SEC + self._player.position = position * NS_TO_US def seek(self, offset: int): - self._player.position = self._player.position / NS_TO_SEC + offset / US_TO_SEC + self._player.position = self._player.position + offset * NS_TO_US def get(self, interface: str, property_name: str) -> GLib.Variant: if property_name in {"CanQuit", "CanControl"}: return GLib.Variant("b", True) elif property_name in {"CanRaise", "HasTrackList"}: return GLib.Variant("b", False) - elif property_name in { - "CanGoNext", - "CanGoPrevious", - "CanPlay", - "CanPause", - "CanSeek", - }: + elif property_name in {"CanGoNext", "CanGoPrevious", "CanPlay", "CanPause", "CanSeek"}: return GLib.Variant("b", self._player.loaded_book is not None) elif property_name in {"SupportedUriSchemes", "SupportedMimeTypes"}: return GLib.Variant("as", []) @@ -281,7 +272,7 @@ def get(self, interface: str, property_name: str) -> GLib.Variant: # Might raise an AttributeError. We handle that in Server.on_method_call return getattr(self, to_snake_case(property_name)) - def get_all(self, interface): + def get_all(self, interface) -> dict[str, GLib.Variant]: if interface == self.MEDIA_PLAYER2_INTERFACE: properties = ( "CanQuit", @@ -305,9 +296,15 @@ def get_all(self, interface): "CanControl", "Volume", ) + else: + return {} return {property: self.get(interface, property) for property in properties} + def set(self, interface: str, property_name: str, value) -> None: + # Might raise an AttributeError. We handle that in Server.on_method_call + return setattr(self, to_snake_case(property_name), value) + def properties_changed(self, iface_name, changed_props, invalidated_props): self._bus.emit_signal( None, @@ -350,6 +347,10 @@ def position(self): def volume(self): return GLib.Variant("d", self._player.volume) + @volume.setter + def volume(self, new_value: float) -> None: + self._player.volume = new_value + def _get_track_id(self) -> float: """ Track IDs must be unique even up to the point that if a song @@ -370,14 +371,18 @@ def _get_new_metadata(self, book: Book | None = None) -> dict[str, GLib.Variant] title=book.current_chapter.name, album=book.name, artist=[book.author], - length=book.current_chapter.length * US_TO_SEC, + length=book.current_chapter.length / NS_TO_US, url=uri_template.format(path=book.current_chapter.file), artwork_uri=self._artwork_cache.get_album_art_path(book, 256), ) return metadata.to_dict() def _on_player_changed(self, event: str, _) -> None: - if event == "chapter-changed": + if event == "position": + self.properties_changed( + self.MEDIA_PLAYER2_PLAYER_INTERFACE, {"Position": self.position}, [] + ) + elif event == "chapter-changed": self._on_current_changed() elif event == "play": self._on_status_changed("Playing") diff --git a/cozy/control/string_representation.py b/cozy/control/string_representation.py deleted file mode 100644 index 5e2a8f3a..00000000 --- a/cozy/control/string_representation.py +++ /dev/null @@ -1,28 +0,0 @@ -def seconds_to_str(seconds, max_length=None, include_seconds=True): - """ - Converts seconds to a string with the following appearance: - hh:mm:ss - - :param seconds: The seconds as float - """ - m, s = divmod(seconds, 60) - h, m = divmod(m, 60) - - if max_length: - max_m, _ = divmod(max_length, 60) - max_h, max_m = divmod(max_m, 60) - else: - max_h = h - max_m = m - - if (max_h >= 10): - result = "%02d:%02d" % (h, m) - elif (max_h >= 1): - result = "%d:%02d" % (h, m) - else: - result = "%02d" % (m) - - if include_seconds: - result += ":%02d" % (s) - - return result diff --git a/cozy/control/time_format.py b/cozy/control/time_format.py new file mode 100644 index 00000000..9c7b5639 --- /dev/null +++ b/cozy/control/time_format.py @@ -0,0 +1,102 @@ +from datetime import datetime +from gettext import ngettext + +from gi.repository import Gst + + +def ns_to_time( + nanoseconds: int, max_length: int | None = None, include_seconds: bool = True +) -> str: + """ + Converts nanoseconds to a string with the following appearance: + hh:mm:ss + + :param nanoseconds: int + """ + m, s = divmod(nanoseconds / Gst.SECOND, 60) + h, m = divmod(m, 60) + + if max_length: + max_m, _ = divmod(max_length, 60) + max_h, max_m = divmod(max_m, 60) + else: + max_h = h + max_m = m + + if max_h >= 10: + result = "%02d:%02d" % (h, m) + elif max_h >= 1: + result = "%d:%02d" % (h, m) + else: + result = "%02d" % m + + if include_seconds: + result += ":%02d" % s + + return result + + +def ns_to_human_readable(nanoseconds: int) -> str: + """ + Create a string with the following format: + 6 hours 1 minute + 45 minutes + 21 seconds + :param seconds: int + """ + m, s = divmod(nanoseconds / Gst.SECOND, 60) + h, m = divmod(m, 60) + h = int(h) + m = int(m) + s = int(s) + + result = "" + if h > 0 and m > 0: + result = ( + ngettext("{hours} hour", "{hours} hours", h).format(hours=h) + + " " + + ngettext("{minutes} minute", "{minutes} minutes", m).format(minutes=m) + ) + elif h > 0: + result = ngettext("{hours} hour", "{hours} hours", h).format(hours=h) + elif m > 0: + result = ngettext("{minutes} minute", "{minutes} minutes", m).format(minutes=m) + elif s > 0: + result = ngettext("{seconds} second", "{seconds} seconds", s).format(seconds=s) + else: + result = _("finished") + + return result + + +def date_delta_to_human_readable(unix_time): + """ + Converts the date to the following strings (from today): + today + yesterday + x days ago + x week(s) ago + x month(s) ago + x year(s) ago + """ + date = datetime.fromtimestamp(unix_time) + past = datetime.today().date() - date.date() + days = int(past.days) + weeks = int(days / 7) + months = int(days / 30) + years = int(months / 12) + + if unix_time < 1: + return _("never") + elif days < 1: + return _("today") + elif days < 2: + return _("yesterday") + elif days < 7: + return _("{days} days ago").format(days=days) + elif weeks < 5: + return ngettext("{weeks} week ago", "{weeks} weeks ago", weeks).format(weeks=weeks) + elif months < 12: + return ngettext("{months} month ago", "{months} months ago", months).format(months=months) + else: + return ngettext("{years} year ago", "{years} years ago", years).format(years=years) diff --git a/cozy/media/gst_player.py b/cozy/media/gst_player.py deleted file mode 100644 index cea6c53c..00000000 --- a/cozy/media/gst_player.py +++ /dev/null @@ -1,274 +0,0 @@ -import logging -import os -import threading -import time -from enum import Enum, auto -from typing import Optional - -from gi.repository import Gst - -from cozy.architecture.event_sender import EventSender -from cozy.report import reporter - -log = logging.getLogger("gst_player") - - -class GstPlayerState(Enum): - STOPPED = auto() - PAUSED = auto() - PLAYING = auto() - - -class GstPlayer(EventSender): - def __init__(self): - super().__init__() - - self._bus: Optional[Gst.Bus] = None - self._player: Optional[Gst.Bin] = None - self._bus_signal_id: Optional[int] = None - self._playback_speed: float = 1.0 - self._playback_speed_timer_running: bool = False - self._volume: float = 1.0 - - Gst.init(None) - - @property - def position(self) -> int: - if not self._is_player_loaded(): - return 0 - - position = self._query_gst_time(self._player.query_position) - - if position: - return position - - log.warning("Failed to query position from player.") - reporter.warning("gst_player", "Failed to query position from player.") - - return 0 - - @position.setter - def position(self, new_position_ns: int): - new_position_ns = max(0, new_position_ns) - duration = self._query_gst_time(self._player.query_duration) - - if duration: - new_position_ns = min(new_position_ns, duration) - - self._execute_seek(new_position_ns) - - @property - def playback_speed(self) -> float: - return self._playback_speed - - @playback_speed.setter - def playback_speed(self, value: float): - if not self._is_player_loaded(): - return - - self._playback_speed = value - - if self._playback_speed_timer_running: - return - - self._playback_speed_timer_running = True - - t = threading.Timer(0.2, self._on_playback_speed_timer) - t.name = "PlaybackSpeedDelayTimer" - t.start() - - @property - def loaded_file_path(self) -> Optional[str]: - if not self._is_player_loaded(): - return None - - uri = self._player.get_property("current-uri") - if uri: - return uri.replace("file://", "") - else: - return None - - @property - def state(self) -> GstPlayerState: - if not self._is_player_loaded(): - return GstPlayerState.STOPPED - - _, state, __ = self._player.get_state(Gst.CLOCK_TIME_NONE) - if state == Gst.State.PLAYING: - return GstPlayerState.PLAYING - elif state == Gst.State.PAUSED: - return GstPlayerState.PAUSED - else: - log.debug("GST player state was not playing or paused but %s", state) - return GstPlayerState.STOPPED - - @property - def volume(self) -> float: - if not self._is_player_loaded(): - log.error("Could not determine volume because player is not loaded.") - return 1.0 - - return self._player.get_property("volume") - - @volume.setter - def volume(self, new_value: float): - self._volume = max(0.0, min(1.0, new_value)) - - if not self._is_player_loaded(): - log.warning("Could not set volume because player is not loaded.") - return - - self._player.set_property("volume", self._volume) - self._player.set_property("mute", False) - - def init(self): - if self._player: - self.dispose() - - self._player = Gst.ElementFactory.make("playbin", "player") - scaletempo = Gst.ElementFactory.make("scaletempo", "scaletempo") - scaletempo.sync_state_with_parent() - - audiobin = Gst.ElementFactory.make("bin", "audiosink") - audiobin.add(scaletempo) - - audiosink = Gst.ElementFactory.make("autoaudiosink", "audiosink") - audiobin.add(audiosink) - - scaletempo.link(audiosink) - pad = scaletempo.get_static_pad("sink") - ghost_pad = Gst.GhostPad.new("sink", pad) - audiobin.add_pad(ghost_pad) - - self._player.set_property("audio-sink", audiobin) - - self._bus = self._player.get_bus() - self._bus.add_signal_watch() - self._bus_signal_id = self._bus.connect("message", self._on_gst_message) - - def dispose(self): - if not self._player: - return - - if self._bus_signal_id: - self._bus.disconnect(self._bus_signal_id) - - self._player.set_state(Gst.State.NULL) - self._playback_speed = 1.0 - log.info("Dispose") - self.emit_event("dispose") - - def load_file(self, path: str): - self.init() - - if not os.path.exists(path): - raise FileNotFoundError() - - self._player.set_property("uri", "file://" + path) - self._player.set_state(Gst.State.PAUSED) - self._player.set_property("volume", self._volume) - self._player.set_property("mute", False) - - def play(self): - if not self._is_player_loaded() or self.state == GstPlayerState.PLAYING: - return - - success = self._player.set_state(Gst.State.PLAYING) - - if success == Gst.StateChangeReturn.FAILURE: - log.warning("Failed set gst player to play.") - reporter.warning("gst_player", "Failed set gst player to play.") - else: - self.emit_event("state", GstPlayerState.PLAYING) - - def pause(self): - if not self._is_player_loaded(): - return - - success = self._player.set_state(Gst.State.PAUSED) - - if success == Gst.StateChangeReturn.FAILURE: - log.warning("Failed set gst player to pause.") - reporter.warning("gst_player", "Failed set gst player to pause.") - else: - self.emit_event("state", GstPlayerState.PAUSED) - - def stop(self): - if not self._is_player_loaded(): - return - - self.dispose() - self.emit_event("state", GstPlayerState.STOPPED) - - def _is_player_loaded(self) -> bool: - if not self._player: - return False - - _, state, __ = self._player.get_state(Gst.CLOCK_TIME_NONE) - if state != Gst.State.PLAYING and state != Gst.State.PAUSED: - return False - - return True - - @staticmethod - def _query_gst_time(query_function) -> Optional[int]: - success = False - counter = 0 - - while not success and counter < 10: - success, value = query_function(Gst.Format.TIME) - - if success: - return value - else: - counter += 1 - time.sleep(0.01) - - return None - - def _execute_seek(self, new_position_ns: int): - counter = 0 - seeked = False - while not seeked and counter < 500: - seeked = self._player.seek(self._playback_speed, Gst.Format.TIME, Gst.SeekFlags.FLUSH, Gst.SeekType.SET, - new_position_ns, Gst.SeekType.NONE, 0) - - if not seeked: - counter += 1 - time.sleep(0.01) - if not seeked: - log.info("Failed to seek, counter expired.") - reporter.warning("gst_player", "Failed to seek, counter expired.") - - def _on_playback_speed_timer(self): - self._player.seek(self._playback_speed, Gst.Format.TIME, Gst.SeekFlags.FLUSH | Gst.SeekFlags.ACCURATE, - Gst.SeekType.SET, self.position, Gst.SeekType.NONE, 0) - - self._playback_speed_timer_running = False - - def _on_gst_message(self, _, message: Gst.Message): - t = message.type - if t == Gst.MessageType.BUFFERING: - if message.percentage < 100: - self._player.set_state(Gst.State.PAUSED) - log.info("Buffering…") - else: - self._player.set_state(Gst.State.PLAYING) - log.info("Buffering finished.") - elif t == Gst.MessageType.EOS: - self.emit_event("file-finished") - elif t == Gst.MessageType.ERROR: - error, debug_msg = message.parse_error() - - if error.code == Gst.ResourceError.NOT_FOUND: - self.stop() - self.emit_event("resource-not-found") - - log.warning("gst: Resource not found. Stopping player.") - reporter.warning("gst_player", "gst: Resource not found. Stopping player.") - return - - reporter.error("player", f"{error.code}: {error}") - log.error("%s: %s", error.code, error) - log.debug(debug_msg) - self.emit_event("error", error) diff --git a/cozy/media/player.py b/cozy/media/player.py index 5db7ac63..be57d7b6 100644 --- a/cozy/media/player.py +++ b/cozy/media/player.py @@ -1,16 +1,14 @@ import logging import os import time -from threading import Thread from typing import Optional -from gi.repository import GLib, Gst +from gi.repository import GLib, Gst, GstController from cozy.application_settings import ApplicationSettings from cozy.architecture.event_sender import EventSender from cozy.control.offline_cache import OfflineCache from cozy.ext import inject -from cozy.media.gst_player import GstPlayer, GstPlayerState from cozy.media.importer import Importer, ScanStatus from cozy.model.book import Book from cozy.model.chapter import Chapter @@ -20,10 +18,301 @@ from cozy.ui.file_not_found_dialog import FileNotFoundDialog from cozy.ui.toaster import ToastNotifier -log = logging.getLogger("mediaplayer") +log = logging.getLogger(__name__) -US_TO_SEC = 10 ** 6 -NS_TO_SEC = 10 ** 9 + +class GstPlayer(EventSender): + _player: Gst.Bin + + def __init__(self): + super().__init__() + + self._playback_speed: float = 1.0 + self._playback_speed_timer_running: bool = False + self._volume: float = 1.0 + self._fade_timeout: int | None = None + + self._setup_pipeline() + self._setup_fadeout_control() + + bus = self._player.get_bus() + bus.add_signal_watch() + bus.connect("message", self._on_gst_message) + + def _setup_pipeline(self): + Gst.init(None) + + audio_sink = Gst.Bin.new() + + scaletempo = Gst.ElementFactory.make("scaletempo", "scaletempo") + scaletempo.sync_state_with_parent() + + self._volume_fader = Gst.ElementFactory.make("volume", "fadevolume") + audiosink = Gst.ElementFactory.make("autoaudiosink", "autoaudiosink") + + audio_sink.add(self._volume_fader) + audio_sink.add(scaletempo) + audio_sink.add(audiosink) + + self._volume_fader.link(scaletempo) + scaletempo.link(audiosink) + + ghost_pad = Gst.GhostPad.new("sink", self._volume_fader.get_static_pad("sink")) + audio_sink.add_pad(ghost_pad) + + self._player = Gst.ElementFactory.make("playbin", "player") + self._player.set_property("audio-sink", audio_sink) + + def _setup_fadeout_control(self): + self.fadeout_control_source = GstController.InterpolationControlSource( + mode=GstController.InterpolationMode.LINEAR + ) + + fadeout_control_binding = GstController.DirectControlBinding( + object=self._volume_fader, + name="volume", + absolute=True, + control_source=self.fadeout_control_source, + ) + + self._volume_fader.add_control_binding(fadeout_control_binding) + + @property + def position(self) -> int: + """Returns the player position in nanoseconds""" + + if not self._is_player_loaded(): + return 0 + + position = self._query_gst_time(self._player.query_position) + + if position: + return position + + log.warning("Failed to query position from player.") + reporter.warning("gst_player", "Failed to query position from player.") + + return 0 + + @position.setter + def position(self, new_position_ns: int): + if new_position_ns != 0: + new_position_ns = max(0, new_position_ns) + duration = self._query_gst_time(self._player.query_duration) + + if duration: + new_position_ns = min(new_position_ns, duration) + + self._execute_seek(new_position_ns) + + @property + def playback_speed(self) -> float: + return self._playback_speed + + @playback_speed.setter + def playback_speed(self, value: float): + if not self._is_player_loaded(): + return + + self._playback_speed = value + + if self._playback_speed_timer_running: + return + + self._playback_speed_timer_running = True + + GLib.timeout_add(200, self._on_playback_speed_timer) + + @property + def loaded_file_path(self) -> Optional[str]: + if not self._is_player_loaded(): + return None + + uri = self._player.get_property("current-uri") + if uri: + return uri.replace("file://", "") + else: + return None + + @property + def state(self) -> Gst.State: + if not self._is_player_loaded(): + return Gst.State.READY + + _, state, __ = self._player.get_state(Gst.CLOCK_TIME_NONE) + if state == Gst.State.PLAYING: + return Gst.State.PLAYING + elif state == Gst.State.PAUSED: + return Gst.State.PAUSED + else: + log.debug("GST player state was not playing or paused but %s", state) + return Gst.State.READY + + @property + def volume(self) -> float: + return self._player.get_property("volume") + + @volume.setter + def volume(self, new_value: float): + self._volume = max(0.0, min(1.0, new_value)) + self._player.set_property("volume", self._volume) + self._player.set_property("mute", False) + + def load_file(self, path: str): + if not os.path.exists(path): + raise FileNotFoundError() + + self._player.set_state(Gst.State.NULL) + self._playback_speed = 1.0 + self._player.set_property("uri", "file://" + path) + self._player.set_state(Gst.State.PAUSED) + + def play(self): + if not self._is_player_loaded() or self.state == Gst.State.PLAYING: + return + + success = self._player.set_state(Gst.State.PLAYING) + + if success == Gst.StateChangeReturn.FAILURE: + log.warning("Failed set gst player to play.") + reporter.warning("gst_player", "Failed set gst player to play.") + else: + self.emit_event("state", Gst.State.PLAYING) + + def pause(self): + if not self._is_player_loaded(): + return + + success = self._player.set_state(Gst.State.PAUSED) + + if success == Gst.StateChangeReturn.FAILURE: + log.warning("Failed set gst player to pause.") + reporter.warning("gst_player", "Failed set gst player to pause.") + else: + self.emit_event("state", Gst.State.PAUSED) + + def stop(self): + if not self._is_player_loaded(): + return + + self._player.set_state(Gst.State.NULL) + self._playback_speed = 1.0 + + self.emit_event("state", Gst.State.READY) + + def _fadeout_callback(self) -> None: + self.fadeout_control_source.unset_all() + + if self._fade_timeout: + GLib.source_remove(self._fade_timeout) + self._fade_timeout = None + + self.pause() + self._volume_fader.props.volume = 1.0 + + self.emit_event("fadeout-finished", None) + + def fadeout(self, length: int) -> None: + if not self._is_player_loaded(): + return + + position = self._query_gst_time(self._player.query_position) + duration = self._query_gst_time(self._player.query_duration) + + if position is None or duration is None: + return + + end_position = min(position + length * Gst.SECOND, duration) + + log.info("Starting playback fadeout") + + self.fadeout_control_source.set(position, 1.0) + self.fadeout_control_source.set(end_position, 0.0) + + self._fade_timeout = GLib.timeout_add( + (end_position - position) // Gst.MSECOND, self._fadeout_callback + ) + + def _is_player_loaded(self) -> bool: + _, state, __ = self._player.get_state(Gst.CLOCK_TIME_NONE) + return state in (Gst.State.PLAYING, Gst.State.PAUSED) + + @staticmethod + def _query_gst_time(query_function) -> Optional[int]: + success = False + counter = 0 + + while not success and counter < 10: + success, value = query_function(Gst.Format.TIME) + + if success: + return value + else: + counter += 1 + time.sleep(0.01) + + return None + + def _execute_seek(self, new_position_ns: int): + counter = 0 + seeked = False + while not seeked and counter < 500: + seeked = self._player.seek( + self._playback_speed, + Gst.Format.TIME, + Gst.SeekFlags.FLUSH, + Gst.SeekType.SET, + new_position_ns, + Gst.SeekType.NONE, + 0, + ) + + if not seeked: + counter += 1 + time.sleep(0.01) + if not seeked: + log.info("Failed to seek, counter expired.") + reporter.warning("gst_player", "Failed to seek, counter expired.") + + def _on_playback_speed_timer(self): + self._player.seek( + self._playback_speed, + Gst.Format.TIME, + Gst.SeekFlags.FLUSH | Gst.SeekFlags.ACCURATE, + Gst.SeekType.SET, + self.position, + Gst.SeekType.NONE, + 0, + ) + + self._playback_speed_timer_running = False + + def _on_gst_message(self, _, message: Gst.Message): + t = message.type + if t == Gst.MessageType.BUFFERING: + if message.percentage < 100: + self._player.set_state(Gst.State.PAUSED) + log.info("Buffering…") + else: + self._player.set_state(Gst.State.PLAYING) + log.info("Buffering finished.") + elif t == Gst.MessageType.EOS: + self.emit_event("file-finished") + elif t == Gst.MessageType.ERROR: + error, debug_msg = message.parse_error() + + if error.code == Gst.ResourceError.NOT_FOUND: + self.stop() + self.emit_event("resource-not-found") + + log.warning("gst: Resource not found. Stopping player.") + reporter.warning("gst_player", "gst: Resource not found. Stopping player.") + return + + reporter.error("player", f"{error.code}: {error}") + log.error("%s: %s", error.code, error) + log.debug(debug_msg) + self.emit_event("error", error) class Player(EventSender): @@ -32,7 +321,6 @@ class Player(EventSender): _offline_cache: OfflineCache = inject.attr(OfflineCache) _toast: ToastNotifier = inject.attr(ToastNotifier) _importer: Importer = inject.attr(Importer) - _gst_player: GstPlayer = inject.attr(GstPlayer) def __init__(self): @@ -45,9 +333,7 @@ def __init__(self): self._gst_player.add_listener(self._on_gst_player_event) self.play_status_updater: IntervalTimer = IntervalTimer(1, self._emit_tick) - self._fadeout_thread: Optional[Thread] = None - self._gst_player.init() self.volume = self._app_settings.volume self._load_last_book() @@ -72,7 +358,7 @@ def loaded_chapter(self) -> Optional[Chapter]: @property def playing(self) -> bool: - return self._gst_player.state == GstPlayerState.PLAYING + return self._gst_player.state == Gst.State.PLAYING @property def position(self) -> int: @@ -80,9 +366,8 @@ def position(self) -> int: @position.setter def position(self, new_value: int): - # FIXME: setter expects seconds, but getter returns nanoseconds if self.loaded_chapter is not None: - self._gst_player.position = max(self.loaded_chapter.start_position + (new_value * NS_TO_SEC), 0) + self._gst_player.position = max(self.loaded_chapter.start_position + new_value, 0) @property def volume(self) -> float: @@ -110,22 +395,20 @@ def playback_speed(self, value: float): self._gst_player.playback_speed = value def play_pause(self): - if self._gst_player.state == GstPlayerState.PAUSED: + if self._gst_player.state == Gst.State.PAUSED: self._gst_player.play() - elif self._gst_player.state == GstPlayerState.PLAYING: + elif self._gst_player.state == Gst.State.PLAYING: self._gst_player.pause() else: log.error("Trying to play/pause although player is in STOP state.") reporter.error("player", "Trying to play/pause although player is in STOP state.") def pause(self, fadeout: bool = False): - if fadeout and not self._fadeout_thread: - log.info("Starting fadeout playback") - self._fadeout_thread = Thread(target=self._fadeout_playback, name="PlayerFadeoutThread") - self._fadeout_thread.start() + if fadeout: + self._gst_player.fadeout(self._app_settings.sleep_timer_fadeout_duration) return - if self._gst_player.state == GstPlayerState.PLAYING: + if self._gst_player.state == Gst.State.PLAYING: self._gst_player.pause() def play_pause_book(self, book: Book): @@ -160,24 +443,20 @@ def play_pause_chapter(self, book: Book, chapter: Chapter): def rewind(self): state = self._gst_player.state - if state != GstPlayerState.STOPPED: + if state != Gst.State.READY: self._rewind_in_book() - if state == GstPlayerState.PLAYING: + if state == Gst.State.PLAYING: self._gst_player.play() def forward(self): state = self._gst_player.state - if state != GstPlayerState.STOPPED: + if state != Gst.State.READY: self._forward_in_book() - if state == GstPlayerState.PLAYING: + if state == Gst.State.PLAYING: self._gst_player.play() def destroy(self): - self._gst_player.dispose() - self._stop_playback() - - if self._fadeout_thread: - self._fadeout_thread.stop() + self._gst_player.stop() def _load_book(self, book: Book): if self._book == book: @@ -218,8 +497,8 @@ def _load_chapter(self, chapter: Chapter): return if file_changed or self._should_jump_to_chapter_position(chapter.position): - self._gst_player.position = chapter.position self._gst_player.playback_speed = self._book.playback_speed + self._gst_player.position = chapter.position if file_changed or self._book.position != chapter.id: self._book.position = chapter.id @@ -243,15 +522,16 @@ def _rewind_in_book(self): current_position = self._gst_player.position current_position_relative = max(current_position - self.loaded_chapter.start_position, 0) chapter_number = self._book.chapters.index(self._book.current_chapter) - rewind_seconds = self._app_settings.rewind_duration * self.playback_speed + rewind_nanoseconds = self._app_settings.rewind_duration * Gst.SECOND * self.playback_speed - if current_position_relative / NS_TO_SEC - rewind_seconds > 0: - self._gst_player.position = current_position - NS_TO_SEC * rewind_seconds + if current_position_relative - rewind_nanoseconds > 0: + self._gst_player.position = current_position - rewind_nanoseconds elif chapter_number > 0: previous_chapter = self._book.chapters[chapter_number - 1] self._load_chapter(previous_chapter) self._gst_player.position = previous_chapter.end_position + ( - current_position_relative - NS_TO_SEC * rewind_seconds) + current_position_relative - rewind_nanoseconds + ) else: self._gst_player.position = 0 @@ -265,15 +545,16 @@ def _forward_in_book(self): current_position_relative = max(current_position - self.loaded_chapter.start_position, 0) old_chapter = self._book.current_chapter chapter_number = self._book.chapters.index(self._book.current_chapter) - forward_seconds = self._app_settings.forward_duration * self.playback_speed + forward_nanoseconds = self._app_settings.forward_duration * Gst.SECOND * self.playback_speed - if current_position_relative / NS_TO_SEC + forward_seconds < self._book.current_chapter.length: - self._gst_player.position = current_position + (NS_TO_SEC * forward_seconds) + if current_position_relative + forward_nanoseconds < self._book.current_chapter.length: + self._gst_player.position = current_position + forward_nanoseconds elif chapter_number < len(self._book.chapters) - 1: next_chapter = self._book.chapters[chapter_number + 1] self._load_chapter(next_chapter) self._gst_player.position = next_chapter.start_position + ( - NS_TO_SEC * forward_seconds - (old_chapter.length * NS_TO_SEC - current_position_relative)) + forward_nanoseconds - old_chapter.length - current_position_relative + ) else: self._next_chapter() @@ -285,7 +566,9 @@ def _rewind_feature(self): def _next_chapter(self): if not self._book: log.error("Cannot play next chapter because no book reference is stored.") - reporter.error("player", "Cannot play next chapter because no book reference is stored.") + reporter.error( + "player", "Cannot play next chapter because no book reference is stored." + ) return index_current_chapter = self._book.chapters.index(self._book.current_chapter) @@ -315,14 +598,14 @@ def _on_gst_player_event(self, event: str, message): self._next_chapter() elif event == "resource-not-found": self._handle_file_not_found() - elif event == "state" and message == GstPlayerState.PLAYING: + elif event == "state" and message == Gst.State.PLAYING: self._book.last_played = int(time.time()) self._start_tick_thread() self.emit_event_main_thread("play", self._book) - elif event == "state" and message == GstPlayerState.PAUSED: + elif event == "state" and message == Gst.State.PAUSED: self._stop_tick_thread() self.emit_event_main_thread("pause") - elif event == "state" and message == GstPlayerState.STOPPED: + elif event == "state" and message == Gst.State.READY: self._stop_playback() elif event == "error": self._handle_gst_error(message) @@ -381,29 +664,10 @@ def _emit_tick(self): except Exception as e: log.warning("Could not emit position event: %s", e) - def _fadeout_playback(self): - duration = self._app_settings.sleep_timer_fadeout_duration * 20 - current_vol = self._gst_player.volume - for i in range(duration): - volume = max(current_vol - (i / duration), 0) - self._gst_player.position = volume - time.sleep(0.05) - - log.info("Fadeout completed.") - self.play_pause() - self._gst_player.volume = current_vol - self.emit_event_main_thread("fadeout-finished", None) - - self._fadeout_thread = None - def _should_jump_to_chapter_position(self, position: int) -> bool: """ Should the player jump to the given position? This allows gapless playback for media files that contain many chapters. """ - difference = abs(self.position - position) - if difference < 10 ** 9: - return False - - return True + return not abs(self.position - position) < Gst.SECOND diff --git a/cozy/media/tag_reader.py b/cozy/media/tag_reader.py index 4263eba8..ef855678 100644 --- a/cozy/media/tag_reader.py +++ b/cozy/media/tag_reader.py @@ -173,7 +173,4 @@ def _mutagen_supports_chapters() -> bool: if mutagen.version[0] > 1: return True - if mutagen.version[0] == 1 and mutagen.version[1] >= 45: - return True - - return False + return mutagen.version[0] == 1 and mutagen.version[1] >= 45 diff --git a/cozy/model/book.py b/cozy/model/book.py index 8d7e359a..010dd712 100644 --- a/cozy/model/book.py +++ b/cozy/model/book.py @@ -174,7 +174,7 @@ def progress(self): for chapter in self.chapters: if chapter.id == self.position: relative_position = max(chapter.position - chapter.start_position, 0) - progress += int(relative_position / 1000000000) + progress += relative_position return progress progress += chapter.length diff --git a/cozy/model/database_importer.py b/cozy/model/database_importer.py index 64ebd43c..ea0edfcf 100644 --- a/cozy/model/database_importer.py +++ b/cozy/model/database_importer.py @@ -197,7 +197,7 @@ def _update_book_position(self, book: BookModel, progress: int): 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) + chapter.position = chapter.start_position + (old_position - completed_chapter_length) book_model.position = chapter.id return else: diff --git a/cozy/model/track.py b/cozy/model/track.py index 6a6b74a0..497fd13a 100644 --- a/cozy/model/track.py +++ b/cozy/model/track.py @@ -2,12 +2,13 @@ from peewee import DoesNotExist, SqliteDatabase +from gi.repository import Gst + from cozy.db.file import File from cozy.db.track import Track as TrackModel from cozy.db.track_to_file import TrackToFile from cozy.model.chapter import Chapter -NS_TO_SEC = 10 ** 9 log = logging.getLogger("TrackModel") @@ -75,7 +76,7 @@ def start_position(self) -> int: @property def end_position(self) -> int: - return self.start_position + (int(self.length) * NS_TO_SEC) + return self.start_position + self.length @property def file(self): @@ -96,11 +97,11 @@ def file_id(self): @property def length(self) -> float: - return self._db_object.length + return int(self._db_object.length * Gst.SECOND) @length.setter def length(self, new_length: float): - self._db_object.length = new_length + self._db_object.length = new_length / Gst.SECOND self._db_object.save(only=self._db_object.dirty_fields) @property diff --git a/cozy/tools.py b/cozy/tools.py index 6a033c35..b49d23a5 100644 --- a/cozy/tools.py +++ b/cozy/tools.py @@ -1,7 +1,5 @@ import threading import time -from datetime import datetime -from gettext import ngettext # https://stackoverflow.com/questions/323972/is-there-any-way-to-kill-a-thread-in-python @@ -32,68 +30,3 @@ def run(self): while not self.stopped(): self._worker_func() time.sleep(self._interval) - - -def seconds_to_human_readable(seconds): - """ - Create a string with the following format: - 6 hours 1 minute - 45 minutes - 21 seconds - :param seconds: Integer - """ - m, s = divmod(seconds, 60) - h, m = divmod(m, 60) - h = int(h) - m = int(m) - s = int(s) - - result = "" - if h > 0 and m > 0: - result = ngettext('{hours} hour', '{hours} hours', h).format(hours=h) + \ - " " + \ - ngettext('{minutes} minute', '{minutes} minutes', m).format(minutes=m) - elif h > 0: - result = ngettext('{hours} hour', '{hours} hours', h).format(hours=h) - elif m > 0: - result = ngettext('{minutes} minute', '{minutes} minutes', m).format(minutes=m) - elif s > 0: - result = ngettext('{seconds} second', '{seconds} seconds', s).format(seconds=s) - else: - result = _("finished") - - return result - - -def past_date_to_human_readable(unix_time): - """ - Converts the date to the following strings (from today): - today - yesterday - x days ago - x week(s) ago - x month(s) ago - x year(s) ago - :param unix_time: - """ - date = datetime.fromtimestamp(unix_time) - past = datetime.today().date() - date.date() - days = int(past.days) - weeks = int(days / 7) - months = int(days / 30) - years = int(months / 12) - - if unix_time < 1: - return _("never") - elif days < 1: - return _("today") - elif days < 2: - return _("yesterday") - elif days < 7: - return _("%s days ago") % str(days) - elif weeks < 5: - return ngettext('{weeks} week ago', '{weeks} weeks ago', weeks).format(weeks=weeks) - elif months < 12: - return ngettext('{months} month ago', '{months} months ago', months).format(months=months) - else: - return ngettext('{years} year ago', '{years} years ago', years).format(years=years) diff --git a/cozy/ui/chapter_element.py b/cozy/ui/chapter_element.py index 51d1d7ff..8a566f26 100644 --- a/cozy/ui/chapter_element.py +++ b/cozy/ui/chapter_element.py @@ -1,6 +1,6 @@ from gi.repository import Gdk, GObject, Gtk -from cozy.control.string_representation import seconds_to_str +from cozy.control.time_format import ns_to_time from cozy.model.chapter import Chapter @@ -31,7 +31,7 @@ def __init__(self, chapter: Chapter): self.number_label.set_text(str(self.chapter.number)) self.title_label.set_text(self.chapter.name) - self.duration_label.set_text(seconds_to_str(self.chapter.length)) + self.duration_label.set_text(ns_to_time(self.chapter.length)) @GObject.Signal(arg_types=(object,)) def play_pause_clicked(self, *_): ... diff --git a/cozy/ui/widgets/seek_bar.py b/cozy/ui/widgets/seek_bar.py index 28c7a73f..18303b68 100644 --- a/cozy/ui/widgets/seek_bar.py +++ b/cozy/ui/widgets/seek_bar.py @@ -1,6 +1,6 @@ from gi.repository import Gdk, GObject, Gtk -from cozy.control.string_representation import seconds_to_str +from cozy.control.time_format import ns_to_time @Gtk.Template.from_resource('/com/github/geigi/cozy/ui/seek_bar.ui') @@ -82,8 +82,8 @@ def _on_progress_scale_changed(self, _): position = int(total * self.progress_scale.get_value() / 100) remaining_secs = int(total - position) - self.current_label.set_text(seconds_to_str(position, total)) - self.remaining_label.set_text(seconds_to_str(remaining_secs, total)) + self.current_label.set_text(ns_to_time(position, total)) + self.remaining_label.set_text(ns_to_time(remaining_secs, total)) def _on_progress_scale_release(self, *_): self._progress_scale_pressed = False diff --git a/cozy/view_model/book_detail_view_model.py b/cozy/view_model/book_detail_view_model.py index 4eb7e9c9..5b29eec8 100644 --- a/cozy/view_model/book_detail_view_model.py +++ b/cozy/view_model/book_detail_view_model.py @@ -1,4 +1,4 @@ -from cozy import tools +from cozy.control import time_format from cozy.application_settings import ApplicationSettings from cozy.architecture.event_sender import EventSender from cozy.architecture.observable import Observable @@ -79,14 +79,14 @@ def last_played_text(self) -> str | None: if not self._book: return None - return tools.past_date_to_human_readable(self._book.last_played) + return time_format.date_delta_to_human_readable(self._book.last_played) @property def total_text(self) -> str | None: if not self._book: return None - return tools.seconds_to_human_readable(self._book.duration / self._book.playback_speed) + return time_format.ns_to_human_readable(self._book.duration / self._book.playback_speed) @property def remaining_text(self) -> str | None: @@ -94,7 +94,7 @@ def remaining_text(self) -> str | None: return None remaining = self._book.duration - self._book.progress - return tools.seconds_to_human_readable(remaining / self._book.playback_speed) + return time_format.ns_to_human_readable(remaining / self._book.playback_speed) @property def progress_percent(self) -> float | None: diff --git a/cozy/view_model/playback_control_view_model.py b/cozy/view_model/playback_control_view_model.py index 8255a49d..c6836f18 100644 --- a/cozy/view_model/playback_control_view_model.py +++ b/cozy/view_model/playback_control_view_model.py @@ -1,3 +1,5 @@ +from gi.repository import Gst + from cozy.architecture.event_sender import EventSender from cozy.architecture.observable import Observable from cozy.ext import inject @@ -5,8 +7,6 @@ from cozy.model.book import Book from cozy.open_view import OpenView -NS_TO_SEC = 10 ** 9 - class PlaybackControlViewModel(Observable, EventSender): _player: Player = inject.attr(Player) @@ -44,14 +44,14 @@ def position(self) -> float | None: return None position = self._book.current_chapter.position - self._book.current_chapter.start_position - return position / NS_TO_SEC / self._book.playback_speed + return position / Gst.SECOND / self._book.playback_speed @position.setter def position(self, new_value: int): if not self._book: return - self._player.position = new_value * self._book.playback_speed + self._player.position = new_value * Gst.SECOND * self._book.playback_speed @property def length(self) -> float | None: @@ -67,7 +67,7 @@ def relative_position(self) -> float | None: position = self._book.current_chapter.position - self._book.current_chapter.start_position length = self._player.loaded_book.current_chapter.length - return position / NS_TO_SEC / length * 100 + return position / length * 100 @relative_position.setter def relative_position(self, new_value: float) -> None: @@ -75,7 +75,7 @@ def relative_position(self, new_value: float) -> None: return length = self._player.loaded_book.current_chapter.length - self._player.position = new_value / 100 * length + self._player.position = int(length * new_value / 100) @property def lock_ui(self) -> bool: diff --git a/main.py b/main.py index 0ae6f8b2..e6129b79 100755 --- a/main.py +++ b/main.py @@ -33,6 +33,7 @@ gi.require_version('Gdk', '4.0') gi.require_version('Adw', '1') gi.require_version('Gst', '1.0') +gi.require_version('GstController', '1.0') gi.require_version('GstPbutils', '1.0') from gi.repository import Gio, GLib diff --git a/test/cozy/media/test_player.py b/test/cozy/media/test_player.py index bce0522b..7904f896 100644 --- a/test/cozy/media/test_player.py +++ b/test/cozy/media/test_player.py @@ -5,7 +5,7 @@ from cozy.application_settings import ApplicationSettings from cozy.ext import inject -from cozy.media.gst_player import GstPlayer +from cozy.media.player import GstPlayer from cozy.model.library import Library from cozy.model.settings import Settings diff --git a/test/cozy/model/test_book.py b/test/cozy/model/test_book.py index 1c22c141..b61bd8f9 100644 --- a/test/cozy/model/test_book.py +++ b/test/cozy/model/test_book.py @@ -291,10 +291,10 @@ def test_progress_return_progress_for_started_book(peewee_database): book = Book(peewee_database, BookDB.get(1)) chapter = book.chapters[0] - chapter.position = 42 * 1000000000 + chapter.position = 42000000000 book.position = chapter.id - assert book.progress == 42 + assert book.progress == 42000000000 def test_progress_should_be_zero_for_unstarted_book(peewee_database): diff --git a/test/cozy/model/test_database_importer.py b/test/cozy/model/test_database_importer.py index 3afcf41a..529c5557 100644 --- a/test/cozy/model/test_database_importer.py +++ b/test/cozy/model/test_database_importer.py @@ -519,7 +519,7 @@ def test_update_book_position_sets_position_for_multi_chapter_file_correctly(): database_importer = DatabaseImporter() book = Book.get_by_id(11) - database_importer._update_book_position(book, 4251) + database_importer._update_book_position(book, 4251000000000) book = Book.get_by_id(11) assert book.position == 232 @@ -534,10 +534,10 @@ def test_update_book_position_sets_position_for_single_chapter_file_correctly(): database_importer = DatabaseImporter() book = Book.get_by_id(2) - database_importer._update_book_position(book, 4251) + database_importer._update_book_position(book, 4251000000000) book = Book.get_by_id(2) - desired_chapter_position = 4251000000000.0 - ((Track.get_by_id(198).length + Track.get_by_id(197).length) * 10 ** 9) + desired_chapter_position = 4251000000000 - ((Track.get_by_id(198).length + Track.get_by_id(197).length) * 1e9) assert book.position == 194 assert Track.get_by_id(194).position == desired_chapter_position @@ -551,7 +551,7 @@ def test_update_book_position_resets_position_if_it_is_longer_than_the_duration( book = Book.get_by_id(11) book.position = 1 book.save(only=book.dirty_fields) - database_importer._update_book_position(book, 42510) + database_importer._update_book_position(book, 42510000000000) book = Book.get_by_id(11) assert book.position == 0 diff --git a/test/cozy/model/test_track.py b/test/cozy/model/test_track.py index 58efe6ff..fe9ac105 100644 --- a/test/cozy/model/test_track.py +++ b/test/cozy/model/test_track.py @@ -144,7 +144,7 @@ def test_length_returns_default_value(peewee_database): from cozy.db.track import Track as TrackDB track = Track(peewee_database, TrackDB.get(1)) - assert track.length == 42.1 + assert track.length == 42.1 * 1e9 def test_setting_length_updates_in_track_object_and_database(peewee_database): @@ -152,8 +152,8 @@ def test_setting_length_updates_in_track_object_and_database(peewee_database): from cozy.model.track import Track track = Track(peewee_database, TrackDB.get(1)) - track.length = 42.42 - assert track.length == 42.42 + track.length = 42.42 * 1e9 + assert track.length == 42.42 * 1e9 assert TrackDB.get_by_id(1).length == 42.42 From 3dc421702f26b5dbf094aa1ef0fe20b1ecad8f4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benedek=20D=C3=A9v=C3=A9nyi?= Date: Sun, 14 Jul 2024 18:46:55 +0200 Subject: [PATCH 2/3] Update Flatpak test image to master branch (#941) --- .github/workflows/flatpak.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/flatpak.yml b/.github/workflows/flatpak.yml index 8cfe211b..2d77c949 100644 --- a/.github/workflows/flatpak.yml +++ b/.github/workflows/flatpak.yml @@ -10,7 +10,7 @@ jobs: flatpak: runs-on: ubuntu-latest container: - image: bilelmoussaoui/flatpak-github-actions:gnome-46 + image: bilelmoussaoui/flatpak-github-actions:gnome-nightly options: --privileged strategy: From be48baa2bfd2bfa906a43336cb3f52065aa7f039 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benedek=20D=C3=A9v=C3=A9nyi?= Date: Sun, 14 Jul 2024 23:29:37 +0200 Subject: [PATCH 3/3] Book overview redesign (#919) --- .github/workflows/flatpak.yml | 8 +- com.github.geigi.cozy.json | 2 +- cozy/app_controller.py | 4 +- cozy/ui/app_view.py | 7 + cozy/ui/book_detail_view.py | 360 +++++++++-------- cozy/ui/chapter_element.py | 41 +- cozy/ui/disk_element.py | 25 -- cozy/ui/library_view.py | 3 +- cozy/ui/main_view.py | 3 + cozy/ui/widgets/filter_list_box.py | 9 +- cozy/ui/widgets/list_box_extensions.py | 20 - cozy/view_model/app_view_model.py | 3 + cozy/view_model/book_detail_view_model.py | 33 +- data/ui/book_detail.blp | 455 ++++++++-------------- data/ui/chapter_element.blp | 79 ++-- data/ui/chapter_element.ui | 0 data/ui/main_window.blp | 7 - main.py | 7 - po/POTFILES | 1 - 19 files changed, 446 insertions(+), 621 deletions(-) delete mode 100644 cozy/ui/disk_element.py delete mode 100644 cozy/ui/widgets/list_box_extensions.py create mode 100644 data/ui/chapter_element.ui diff --git a/.github/workflows/flatpak.yml b/.github/workflows/flatpak.yml index 2d77c949..ec0f9d42 100644 --- a/.github/workflows/flatpak.yml +++ b/.github/workflows/flatpak.yml @@ -22,20 +22,22 @@ jobs: - uses: actions/checkout@v4 - name: Install deps + if: ${{ matrix.arch == 'aarch64' }} run: | dnf -y install docker - if: ${{ matrix.arch == 'aarch64' }} - name: Set up QEMU id: qemu + if: ${{ matrix.arch == 'aarch64' }} uses: docker/setup-qemu-action@v3 with: platforms: arm64 - if: ${{ matrix.arch == 'aarch64' }} - uses: bilelmoussaoui/flatpak-github-actions/flatpak-builder@v6 with: + repository-name: gnome-nightly + repository-url: https://nightly.gnome.org/gnome-nightly.flatpakrepo bundle: Cozy.flatpak manifest-path: com.github.geigi.cozy.json cache-key: "flatpak-builder-${{ github.sha }}" - arch: ${{ matrix.arch }} + arch: ${{ matrix.arch }} diff --git a/com.github.geigi.cozy.json b/com.github.geigi.cozy.json index 71689c45..9cd251a5 100644 --- a/com.github.geigi.cozy.json +++ b/com.github.geigi.cozy.json @@ -1,7 +1,7 @@ { "app-id": "com.github.geigi.cozy", "runtime": "org.gnome.Platform", - "runtime-version": "46", + "runtime-version": "master", "sdk": "org.gnome.Sdk", "command": "com.github.geigi.cozy", "finish-args": [ diff --git a/cozy/app_controller.py b/cozy/app_controller.py index f08f4c9b..91296a06 100644 --- a/cozy/app_controller.py +++ b/cozy/app_controller.py @@ -17,7 +17,6 @@ from cozy.power_manager import PowerManager from cozy.report import reporter from cozy.ui.app_view import AppView -from cozy.ui.book_detail_view import BookDetailView from cozy.ui.headerbar import Headerbar from cozy.ui.library_view import LibraryView from cozy.ui.main_view import CozyUI @@ -50,7 +49,6 @@ def __init__(self, gtk_app, main_window_builder, main_window): self.library_view: LibraryView = LibraryView(main_window_builder) self.app_view: AppView = AppView(main_window_builder) self.headerbar: Headerbar = Headerbar(main_window_builder) - self.book_detail_view: BookDetailView = BookDetailView(main_window_builder) self.media_controller: MediaController = MediaController(main_window_builder) self.search_view: SearchView = SearchView(main_window_builder, self.headerbar) @@ -115,8 +113,8 @@ def open_reader(self, reader: str): self.library_view_model.selected_filter = reader def open_book(self, book: Book): - self.book_detail_view_model.open_book_detail_view() self.book_detail_view_model.book = book + self.app_view_model.open_book_detail_view() def open_library(self): self.library_view_model.open_library() diff --git a/cozy/ui/app_view.py b/cozy/ui/app_view.py index feb69606..342c7c72 100644 --- a/cozy/ui/app_view.py +++ b/cozy/ui/app_view.py @@ -32,6 +32,13 @@ def _connect_ui_elements(self): def _connect_view_model(self): self._view_model.bind_to("view", self._on_view_changed) + self._view_model.bind_to("open_book_overview", self._on_open_book_overview) + + def _on_open_book_overview(self): + if self._navigation_view.props.visible_page.props.tag == "book_overview": + self._navigation_view.pop_to_tag("book_overview") + else: + self._navigation_view.push_by_tag("book_overview") def _on_view_changed(self): view = self._view_model.view diff --git a/cozy/ui/book_detail_view.py b/cozy/ui/book_detail_view.py index 7952594c..6ef8e0be 100644 --- a/cozy/ui/book_detail_view.py +++ b/cozy/ui/book_detail_view.py @@ -1,10 +1,11 @@ import logging import time -from contextlib import suppress +from math import pi as PI from threading import Event, Thread -from typing import Callable, Optional +from typing import Callable, Final -from gi.repository import Adw, GLib, Gtk +import cairo +from gi.repository import Adw, Gio, GLib, GObject, Graphene, Gtk from cozy.control.artwork_cache import ArtworkCache from cozy.ext import inject @@ -12,150 +13,192 @@ from cozy.model.chapter import Chapter from cozy.report import reporter from cozy.ui.chapter_element import ChapterElement -from cozy.ui.disk_element import DiskElement +from cozy.ui.toaster import ToastNotifier from cozy.view_model.book_detail_view_model import BookDetailViewModel -log = logging.getLogger("BookDetailView") +log = logging.getLogger(__name__) +ALBUM_ART_SIZE: Final[int] = 256 +PROGRESS_RING_LINE_WIDTH: Final[int] = 5 -ALBUM_ART_SIZE = 256 +def call_in_main_thread(*args) -> None: + # TODO: move this elsewhere, it might come useful + GLib.MainContext.default().invoke_full(GLib.PRIORITY_DEFAULT_IDLE, *args) -@Gtk.Template.from_resource('/com/github/geigi/cozy/ui/book_detail.ui') -class BookDetailView(Gtk.Box): - __gtype_name__ = 'BookDetail' - play_book_button: Gtk.Button = Gtk.Template.Child() +class ProgressRing(Gtk.Widget): + __gtype_name__ = "ProgressRing" + + progress = GObject.Property(type=float, default=0.0) + + def __init__(self) -> None: + super().__init__() + + self._style_manager = Adw.StyleManager() + self._style_manager.connect("notify::accent-color", self.redraw) + self.connect("notify::progress", self.redraw) + + def redraw(self, *_) -> None: + self.queue_draw() + + def do_measure(self, *_) -> tuple[int, int, int, int]: + return (40, 40, -1, -1) + + def do_snapshot(self, snapshot: Gtk.Snapshot) -> None: + size = self.get_allocated_height() + radius = (size - 8) / 2.0 + + context = snapshot.append_cairo(Graphene.Rect().init(0, 0, size, size)) + + context.arc(size / 2, size / 2, radius, 0, 2 * PI) + context.set_source_rgba(*self.get_dim_color()) + context.set_line_width(PROGRESS_RING_LINE_WIDTH) + context.stroke() + + context.arc(size / 2, size / 2, radius, -0.5 * PI, self.progress * 2 * PI - (0.5 * PI)) + context.set_source_rgb(*self.get_accent_color()) + context.set_line_width(PROGRESS_RING_LINE_WIDTH) + context.set_line_cap(cairo.LineCap.ROUND) + context.stroke() + + def get_dim_color(self) -> tuple[int, int, int, int]: + color = self.get_color() + return color.red, color.green, color.blue, 0.15 + + def get_accent_color(self) -> tuple[int, int, int]: + color = self._style_manager.get_accent_color_rgba() + return color.red, color.green, color.blue + + +class ChaptersListBox(Adw.PreferencesGroup): + def __init__(self, title: str): + super().__init__() + self.set_title(title) + + def add_chapter(self, chapter: Chapter, callback: Callable[[None], None]): + chapter_element = ChapterElement(chapter) + chapter_element.connect("play-pause-clicked", callback) + self.add(chapter_element) + return chapter_element + + +@Gtk.Template.from_resource("/com/github/geigi/cozy/ui/book_detail.ui") +class BookDetailView(Adw.NavigationPage): + __gtype_name__ = "BookDetail" + + play_button: Gtk.Button = Gtk.Template.Child() + play_icon: Adw.ButtonContent = Gtk.Template.Child() book_label: Gtk.Label = Gtk.Template.Child() author_label: Gtk.Label = Gtk.Template.Child() - last_played_label: Gtk.Label = Gtk.Template.Child() total_label: Gtk.Label = Gtk.Template.Child() - remaining_label: Gtk.Label = Gtk.Template.Child() - book_progress_bar: Gtk.ProgressBar = Gtk.Template.Child() - - published_label: Gtk.Label = Gtk.Template.Child() - published_text: Gtk.Label = Gtk.Template.Child() - download_box: Gtk.Box = Gtk.Template.Child() - download_label: Gtk.Label = Gtk.Template.Child() - download_image: Gtk.Image = Gtk.Template.Child() - download_switch: Gtk.Switch = Gtk.Template.Child() + book_progress_ring: ProgressRing = Gtk.Template.Child() album_art: Gtk.Picture = Gtk.Template.Child() album_art_container: Gtk.Box = Gtk.Template.Child() - unavailable_box: Gtk.Box = Gtk.Template.Child() + unavailable_banner: Adw.Banner = Gtk.Template.Child() chapters_stack: Gtk.Stack = Gtk.Template.Child() - chapter_box: Gtk.Box = Gtk.Template.Child() - book_overview_scroller: Gtk.ScrolledWindow = Gtk.Template.Child() + chapter_list_container: Gtk.Box = Gtk.Template.Child() - main_flow_box: Gtk.FlowBox = Gtk.Template.Child() + book_title = GObject.Property(type=str) _view_model: BookDetailViewModel = inject.attr(BookDetailViewModel) _artwork_cache: ArtworkCache = inject.attr(ArtworkCache) + _toaster: ToastNotifier = inject.attr(ToastNotifier) - _current_selected_chapter: Optional[ChapterElement] = None + _current_selected_chapter: ChapterElement | None = None - def __init__(self, main_window_builder: Gtk.Builder): + def __init__(self): super().__init__() - self._navigation_view: Adw.NavigationView = main_window_builder.get_object("navigation_view") - self._book_details_container: Adw.ToolbarView = main_window_builder.get_object("book_details_container") - self._book_details_container.set_content(self) + self._chapters_event = Event() + self._chapters_thread: Thread | None = None + self._chapters_job_locked = False - headerbar = Adw.HeaderBar() - self.header_title = Adw.WindowTitle() - headerbar.set_title_widget(self.header_title) - self._book_details_container.add_top_bar(headerbar) - - self.book_overview_scroller.props.propagate_natural_height = True - - self._chapters_event: Event = Event() - self._chapters_thread: Thread = None - self._prepare_chapters_job() + self._chapter_listboxes: list[ChaptersListBox] = [] + self._chapter_elements: list[ChapterElement] = [] self._connect_view_model() self._connect_widgets() + menu_action_group = Gio.SimpleActionGroup() + self.insert_action_group("book_overview", menu_action_group) + + self.available_offline_action = Gio.SimpleAction.new_stateful( + "download", None, GLib.Variant.new_boolean(False) + ) + self.available_offline_action.connect("change-state", self._download_switch_changed) + menu_action_group.add_action(self.available_offline_action) + def _connect_view_model(self): self._view_model.bind_to("book", self._on_book_changed) self._view_model.bind_to("playing", self._on_play_changed) self._view_model.bind_to("is_book_available", self._on_book_available_changed) self._view_model.bind_to("downloaded", self._set_book_download_status) self._view_model.bind_to("current_chapter", self._on_current_chapter_changed) - self._view_model.bind_to("last_played_text", self._on_last_played_text_changed) - self._view_model.bind_to("remaining_text", self._on_times_changed) - self._view_model.bind_to("progress_percent", self._on_times_changed) - self._view_model.bind_to("total_text", self._on_times_changed) - self._view_model.bind_to("playback_speed", self._on_times_changed) + self._view_model.bind_to("length", self._on_length_changed) + self._view_model.bind_to("progress", self._on_progress_changed) + self._view_model.bind_to("playback_speed", self._on_progress_changed) self._view_model.bind_to("lock_ui", self._on_lock_ui_changed) - self._view_model.bind_to("open", self._on_open) def _connect_widgets(self): - self.play_book_button.connect("clicked", self._play_book_clicked) - self.download_switch.connect("state-set", self._download_switch_changed) + self.play_button.connect("clicked", self._play_book_clicked) def _on_book_changed(self): - if not self._view_model.book: - msg = "ViewModel book was None." - log.warning(msg) - reporter.warning("BookDetailView", msg) + book = self._view_model.book + + if not book: + message = "ViewModel book was None." + log.warning(message) + reporter.warning("BookDetailView", message) return self._chapters_event.clear() - book = self._view_model.book - self.chapters_stack.set_visible_child_name("chapters_loader") - self.book_overview_scroller.set_visible(False) - self._run_display_chapters_job(book) + self._display_chapters(book) self._current_selected_chapter = None - self.published_label.set_visible(False) - self.published_text.set_visible(False) self.total_label.set_visible(False) - self.unavailable_box.set_visible(False) + self.unavailable_banner.set_revealed(False) - self.book_label.set_text(book.name) + self.book_title = book.name self.author_label.set_text(book.author) - self.header_title.set_title(book.name) - self.header_title.set_subtitle(book.author) - - self.last_played_label.set_text(self._view_model.last_played_text) self._set_cover_image(book) - + self._on_progress_changed() self._display_external_section() - self._set_progress() - - def _on_open(self): - if self._navigation_view.props.visible_page.props.tag == "book_overview": - self._navigation_view.pop_to_tag("book_overview") - else: - self._navigation_view.push_by_tag("book_overview") def _on_play_changed(self): playing = self._view_model.playing if playing: - self.play_book_button.set_icon_name("media-playback-pause-symbolic") + self.play_icon.set_icon_name("media-playback-pause-symbolic") + self.play_icon.set_label(_("Pause")) else: - self.play_book_button.set_icon_name("media-playback-start-symbolic") + self.play_icon.set_icon_name("media-playback-start-symbolic") + if not self._view_model.progress_percent: + self.play_icon.set_label(_("Start")) + else: + self.play_icon.set_label(_("Resume")) if self._current_selected_chapter: self._current_selected_chapter.set_playing(playing) else: log.error("_current_selected_chapter is null. Skipping...") - reporter.error("book_detail_view", - "_current_selected_chapter was NULL. No play/pause chapter icon was changed") + reporter.error( + "book_detail_view", + "_current_selected_chapter was NULL. No play/pause chapter icon was changed", + ) def _on_book_available_changed(self): - info_visibility = not self._view_model.is_book_available - self.unavailable_box.set_visible(info_visibility) + self.unavailable_banner.set_revealed(not self._view_model.is_book_available) def _on_current_chapter_changed(self): if self._current_selected_chapter: @@ -164,146 +207,141 @@ def _on_current_chapter_changed(self): current_chapter = self._view_model.current_chapter - for child in self.chapter_box: - if not isinstance(child, ChapterElement): - continue - + for child in self._chapter_elements: if child.chapter == current_chapter: self._current_selected_chapter = child child.select() child.set_playing(self._view_model.playing) break - def _on_last_played_text_changed(self): - self.last_played_label.set_text(self._view_model.last_played_text) - - def _on_times_changed(self): + def _on_length_changed(self): self.total_label.set_text(self._view_model.total_text) - self._set_progress() + + def _on_progress_changed(self): + self.remaining_label.set_text(self._view_model.remaining_text) + self.book_progress_ring.progress = self._view_model.progress_percent def _on_lock_ui_changed(self): - lock = self._view_model.lock_ui - self.download_switch.set_sensitive(not lock) + self.available_offline_action.set_enabled(not self._view_model.lock_ui) - def _run_display_chapters_job(self, book): + def _on_chapters_displayed(self): + self.total_label.set_text(self._view_model.total_text) + self.total_label.set_visible(True) + self._set_book_download_status() + + self._on_current_chapter_changed() + self._on_play_changed() + self._on_book_available_changed() + + self.chapters_stack.set_visible_child_name("chapters_wrapper") + + def _display_chapters(self, book): self._chapters_event.clear() + # The job might be running on another thread. Attempt to cancel it first, wait a while and trigger the new one. self._interrupt_chapters_jobs() time.sleep(0.05) - # This is done on a the UI thread to prevent chapters from the previous book flashing before the new chapters - # are ready - self._schedule_chapters_clearing() - self._prepare_chapters_job() - self._chapters_thread: Thread = Thread(target=self._schedule_chapters_rendering, - args=[book, self._on_chapters_displayed]) + + # This is done on a the UI thread to prevent chapters from the previous + # book flashing before the new chapters are ready + self._clear_chapters() + self._chapters_job_locked = False + self._chapters_thread = Thread( + target=self._render_chapters, args=[book, self._on_chapters_displayed] + ) self._chapters_thread.start() - def _schedule_chapters_rendering(self, book: Book, callback: Callable): - disk_number = -1 + def _clear_chapters(self) -> None: + for listbox in self._chapter_listboxes: + call_in_main_thread(self.chapter_list_container.remove, listbox) + + self._chapter_elements.clear() + self._chapter_listboxes.clear() + + def _render_chapters(self, book: Book, callback: Callable) -> None: + if book.id != self._view_model.book.id: + return + multiple_disks = self._view_model.disk_count > 1 + disk_number = -1 + + if not multiple_disks: + call_in_main_thread(self._add_section, _("Chapters")) for chapter in book.chapters: if self._chapters_job_locked: - self._schedule_chapters_clearing() - return + # Rendering was cancelled + return self._clear_chapters() if multiple_disks and disk_number != chapter.disk: - GLib.MainContext.default().invoke_full(GLib.PRIORITY_DEFAULT_IDLE, self._add_disk, book.id, chapter) + disk_number = chapter.disk + section_title = _("Disc {disk_number}").format(disk_number=disk_number) + call_in_main_thread(self._add_section, section_title) - GLib.MainContext.default().invoke_full(GLib.PRIORITY_DEFAULT_IDLE, self._add_chapter, book.id, chapter) - - disk_number = chapter.disk + call_in_main_thread(self._add_chapter, chapter) # TODO We need a timeout value + # Wait until the chapter is displayed self._chapters_event.wait() self._chapters_event.clear() - GLib.MainContext.default().invoke_full(GLib.PRIORITY_DEFAULT_IDLE, callback) + call_in_main_thread(callback) - def _on_chapters_displayed(self): - self.total_label.set_text(self._view_model.total_text) - self.total_label.set_visible(True) - self._set_book_download_status() + def _add_section(self, title: str) -> ChaptersListBox: + listbox = ChaptersListBox(title) + self.chapter_list_container.append(listbox) + self._chapter_listboxes.append(listbox) - self._on_current_chapter_changed() - self._on_play_changed() - self._on_book_available_changed() - - self.book_overview_scroller.set_visible(True) - self.chapters_stack.set_visible_child_name("chapters_wrapper") + def _add_chapter(self, chapter: Chapter): + current_listbox = self._chapter_listboxes[-1] + element = current_listbox.add_chapter(chapter, self._play_chapter_clicked) + self._chapter_elements.append(element) + self._chapters_event.set() def _display_external_section(self): external = self._view_model.is_book_external - self.download_box.set_visible(external) - self.download_switch.set_visible(external) + self.available_offline_action.set_enabled(external) if external: - self.download_switch.handler_block_by_func(self._download_switch_changed) - self.download_switch.set_active(self._view_model.book.offline) - self.download_switch.handler_unblock_by_func(self._download_switch_changed) + self.available_offline_action.handler_block_by_func(self._download_switch_changed) + self.available_offline_action.set_state( + GLib.Variant.new_boolean(self._view_model.book.offline) + ) + self.available_offline_action.handler_unblock_by_func(self._download_switch_changed) - def _add_disk(self, book_id: int, chapter: Chapter): - if book_id != self._view_model.book.id: - return - - disc_element = DiskElement(chapter.disk) - self.chapter_box.append(disc_element) - self._chapters_event.set() - - def _add_chapter(self, book_id: int, chapter: Chapter): - if book_id != self._view_model.book.id: - return - - chapter_element = ChapterElement(chapter) - chapter_element.connect("play-pause-clicked", self._play_chapter_clicked) - self.chapter_box.append(chapter_element) - self._chapters_event.set() - - def _schedule_chapters_clearing(self): - GLib.MainContext.default().invoke_full(GLib.PRIORITY_DEFAULT_IDLE, self.chapter_box.remove_all_children) + def _set_cover_image(self, book: Book): + self.album_art_container.set_visible(False) - def _set_progress(self): - self.remaining_label.set_text(self._view_model.remaining_text) - self.book_progress_bar.set_fraction(self._view_model.progress_percent) + paintable = self._artwork_cache.get_cover_paintable( + book, self.get_scale_factor(), ALBUM_ART_SIZE + ) - def _set_cover_image(self, book: Book): - paintable = self._artwork_cache.get_cover_paintable(book, self.get_scale_factor(), ALBUM_ART_SIZE) if paintable: self.album_art_container.set_visible(True) self.album_art.set_paintable(paintable) self.album_art.set_overflow(True) - else: - self.album_art_container.set_visible(False) def _interrupt_chapters_jobs(self): self._chapters_job_locked = True - with suppress(AttributeError): + if self._chapters_thread: self._chapters_thread.join(timeout=0.2) - def _prepare_chapters_job(self): - self._chapters_job_locked: bool = False - - def _download_switch_changed(self, _, state: bool): - self._view_model.download_book(state) - def _set_book_download_status(self): if not self._view_model.is_book_external: return - if self._view_model.book.downloaded: - icon_name = "downloaded-symbolic" - text = _("Downloaded") - else: - icon_name = "download-symbolic" - text = _("Download") + # TODO: show this only after download + # if self._view_model.book.downloaded: + # self._toaster.show(_("{book_title} is now available offline").format(book_title=self._view_model.book.name)) - self.download_image.set_from_icon_name(icon_name) - self.download_label.set_text(text) + def _download_switch_changed(self, action, value): + action.set_state(value) + self._view_model.download_book(value.get_boolean()) + self._set_book_download_status() def _play_chapter_clicked(self, _, chapter: Chapter): self._view_model.play_chapter(chapter) def _play_book_clicked(self, _): self._view_model.play_book() - diff --git a/cozy/ui/chapter_element.py b/cozy/ui/chapter_element.py index 8a566f26..26bca729 100644 --- a/cozy/ui/chapter_element.py +++ b/cozy/ui/chapter_element.py @@ -1,36 +1,27 @@ -from gi.repository import Gdk, GObject, Gtk +from gi.repository import Adw, GObject, Gtk from cozy.control.time_format import ns_to_time from cozy.model.chapter import Chapter -@Gtk.Template.from_resource('/com/github/geigi/cozy/ui/chapter_element.ui') -class ChapterElement(Gtk.Box): +@Gtk.Template.from_resource("/com/github/geigi/cozy/ui/chapter_element.ui") +class ChapterElement(Adw.ActionRow): __gtype_name__ = "ChapterElement" icon_stack: Gtk.Stack = Gtk.Template.Child() - number_label: Gtk.Label = Gtk.Template.Child() play_icon: Gtk.Image = Gtk.Template.Child() - title_label: Gtk.Label = Gtk.Template.Child() + number_label: Gtk.Label = Gtk.Template.Child() duration_label: Gtk.Label = Gtk.Template.Child() def __init__(self, chapter: Chapter): - self.selected = False - self.chapter = chapter - super().__init__() - gesture = Gtk.GestureClick(button=Gdk.BUTTON_PRIMARY) - gesture.connect("released", self._on_button_press) - self.add_controller(gesture) + self.chapter = chapter - motion = Gtk.EventControllerMotion() - motion.connect("enter", self._on_enter_notify) - motion.connect("leave", self._on_leave_notify) - self.add_controller(motion) + self.connect("activated", self._on_button_press) + self.set_title(self.chapter.name) self.number_label.set_text(str(self.chapter.number)) - self.title_label.set_text(self.chapter.name) self.duration_label.set_text(ns_to_time(self.chapter.length)) @GObject.Signal(arg_types=(object,)) @@ -39,26 +30,10 @@ def play_pause_clicked(self, *_): ... def _on_button_press(self, *_): self.emit("play-pause-clicked", self.chapter) - def _on_enter_notify(self, *_): - self.add_css_class("box_hover") - self.play_icon.add_css_class("box_hover") - - if not self.selected: - self.icon_stack.set_visible_child_name("play_icon") - - def _on_leave_notify(self, *_): - self.remove_css_class("box_hover") - self.play_icon.remove_css_class("box_hover") - - if not self.selected: - self.icon_stack.set_visible_child_name("number") - def select(self): - self.selected = True - self.icon_stack.set_visible_child_name("play_icon") + self.icon_stack.set_visible_child_name("icon") def deselect(self): - self.selected = False self.icon_stack.set_visible_child_name("number") def set_playing(self, playing): diff --git a/cozy/ui/disk_element.py b/cozy/ui/disk_element.py deleted file mode 100644 index c183f0f9..00000000 --- a/cozy/ui/disk_element.py +++ /dev/null @@ -1,25 +0,0 @@ -from gi.repository import Gtk - - -class DiskElement(Gtk.Box): - """ - This class represents a small disk number header for the book overview track list. - """ - - def __init__(self, disc_number): - super().__init__() - self.add_css_class("dim-label") - - if disc_number > 1: - self.set_margin_top(18) - self.set_margin_bottom(3) - self.set_margin_start(6) - - image = Gtk.Image.new_from_icon_name("media-optical-cd-audio-symbolic") - self.append(image) - - label = Gtk.Label(margin_start=5) - text = _("Disc") + " " + str(disc_number) # TODO: use formatted translation string here - label.set_markup(f"{text}") - self.append(label) - diff --git a/cozy/ui/library_view.py b/cozy/ui/library_view.py index 717173f1..f36c724a 100644 --- a/cozy/ui/library_view.py +++ b/cozy/ui/library_view.py @@ -87,7 +87,8 @@ def _on_sort_stack_changed(self, widget, _): self._view_model.library_view_mode = view_mode def populate_book_box(self): - self._book_box.remove_all_children() + for child in self._book_box: + self._book_box.remove(child) for book in self._view_model.books: book_element = BookElement(book) diff --git a/cozy/ui/main_view.py b/cozy/ui/main_view.py index e2b14a1f..966ae2de 100644 --- a/cozy/ui/main_view.py +++ b/cozy/ui/main_view.py @@ -16,6 +16,7 @@ from cozy.media.player import Player from cozy.model.settings import Settings as SettingsModel from cozy.ui.about_window import AboutWindow +from cozy.ui.book_detail_view import BookDetailView from cozy.ui.library_view import LibraryView from cozy.ui.preferences_window import PreferencesWindow from cozy.ui.widgets.first_import_button import FirstImportButton @@ -81,6 +82,8 @@ def __init_window(self): self.navigation_view: Adw.NavigationView = self.window_builder.get_object("navigation_view") self.drop_revealer: Gtk.Revealer = self.window_builder.get_object("drop_revealer") + self.navigation_view.add(BookDetailView()) + self.window.present() def __init_actions(self): diff --git a/cozy/ui/widgets/filter_list_box.py b/cozy/ui/widgets/filter_list_box.py index 269238db..7d4aa349 100644 --- a/cozy/ui/widgets/filter_list_box.py +++ b/cozy/ui/widgets/filter_list_box.py @@ -11,7 +11,7 @@ def __init__(self, **properties): super().__init__(**properties) def populate(self, elements: list[str]): - self.remove_all_children() + self.remove_all() all_row = ListBoxRowWithData(_("All"), True) all_row.set_tooltip_text(_("Display all books")) @@ -23,12 +23,7 @@ def populate(self, elements: list[str]): self.append(row) def select_row_with_content(self, row_content: str): - child = self.get_first_child() - while child: - next = child.get_next_sibling() - + for child in self: if isinstance(child, ListBoxRowWithData) and child.data == row_content: self.select_row(child) break - - child = next diff --git a/cozy/ui/widgets/list_box_extensions.py b/cozy/ui/widgets/list_box_extensions.py deleted file mode 100644 index 82846727..00000000 --- a/cozy/ui/widgets/list_box_extensions.py +++ /dev/null @@ -1,20 +0,0 @@ -from gi.repository import Gtk - - -def remove_all_children(self): - """ - Removes all widgets from a gtk widget. - """ - self.set_visible(False) - - child = self.get_first_child() - while child: - next = child.get_next_sibling() - self.remove(child) - child = next - - self.set_visible(True) - - -def extend_gtk_container(): - Gtk.Widget.remove_all_children = remove_all_children diff --git a/cozy/view_model/app_view_model.py b/cozy/view_model/app_view_model.py index c9d02d3a..6b41ab2a 100644 --- a/cozy/view_model/app_view_model.py +++ b/cozy/view_model/app_view_model.py @@ -10,6 +10,9 @@ def __init__(self): self._view = View.EMPTY_STATE + def open_book_detail_view(self): + self._notify("open_book_overview") + @property def view(self) -> View: return self._view diff --git a/cozy/view_model/book_detail_view_model.py b/cozy/view_model/book_detail_view_model.py index 5b29eec8..81d229dd 100644 --- a/cozy/view_model/book_detail_view_model.py +++ b/cozy/view_model/book_detail_view_model.py @@ -155,9 +155,6 @@ def play_chapter(self, chapter: Chapter): chapter.position = chapter.start_position self._player.play_pause_chapter(self._book, chapter) - def open_book_detail_view(self): - self._notify("open") - def _on_player_event(self, event, message): if not self.book: return @@ -165,8 +162,7 @@ def _on_player_event(self, event, message): if event in {"play", "pause"}: self._notify("playing") elif event in {"position", "book-finished"}: - self._notify("progress_percent") - self._notify("remaining_text") + self._notify("progress") def _on_fs_monitor_event(self, event, _): if not self._book: @@ -182,24 +178,23 @@ 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") + self._notify("progress") def _on_book_duration_changed(self): - self._notify("progress_percent") - self._notify("remaining_text") - self._notify("total_text") + self._notify("progress") + self._notify("length") 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): - if not (message and self._book) or self._book.id != message.id: - return - - if event in {"book-offline-removed", "book-offline"}: + self._notify("progress") + self._notify("length") + + def _on_offline_cache_event(self, event, message) -> None: + if ( + self._book + and isinstance(message, Book) + and self._book.id == message.id + and event in {"book-offline-removed", "book-offline"} + ): self._notify("downloaded") def _on_app_setting_changed(self, event, _): diff --git a/data/ui/book_detail.blp b/data/ui/book_detail.blp index 5a1dc1f2..edca2288 100644 --- a/data/ui/book_detail.blp +++ b/data/ui/book_detail.blp @@ -1,50 +1,71 @@ using Gtk 4.0; using Adw 1; -template $BookDetail: Box { - Box { - orientation: vertical; - - ScrolledWindow { - focusable: true; - valign: center; - vexpand: true; - hscrollbar-policy: never; - propagate-natural-width: true; - propagate-natural-height: true; - - child: Viewport { - child: FlowBox main_flow_box { - valign: center; - margin-start: 12; - margin-end: 12; - margin-top: 12; - margin-bottom: 12; - hexpand: true; +template $BookDetail: Adw.NavigationPage { + tag: 'book_overview'; + title: bind template.book-title; + + Adw.ToolbarView { + [top] + Adw.HeaderBar { + [end] + MenuButton book_menu { + menu-model: book_menu_model; + icon-name: "view-more-symbolic"; + tooltip-text: _("Open Book Menu"); + } + } + + content: Adw.BreakpointBin { + width-request: 350; + height-request: 52; + + Adw.Breakpoint { + condition ("max-width: 550sp") + + setters { + top_box.orientation: vertical; + top_box.spacing: 6; + top_box.halign: center; + book_label.halign: center; + author_label.halign: center; + play_button.halign: center; + } + } + + child: Box { + orientation: vertical; + + Adw.Banner unavailable_banner { + title: _("Some or all files of this book cannot be found."); + } + + ScrolledWindow { + focusable: true; vexpand: true; - column-spacing: 12; - row-spacing: 12; - min-children-per-line: 1; - max-children-per-line: 2; - selection-mode: none; + hscrollbar-policy: never; + propagate-natural-width: true; + propagate-natural-height: true; - FlowBoxChild { - halign: center; - valign: start; + child: Box { + orientation: vertical; - child: Adw.Clamp { - maximum-size: 250; - tightening-threshold: 250; + Adw.Clamp { + maximum-size: 800; - Box { - orientation: vertical; - spacing: 6; + Box top_box { + orientation: horizontal; + spacing: 30; + margin-start: 18; + margin-end: 18; + margin-top: 12; + margin-bottom: 18; + halign: start; Box album_art_container { halign: center; - margin-bottom: 18; - width-request: 256; - height-request: 256; + width-request: 200; + height-request: 200; Picture album_art { styles [ @@ -57,221 +78,114 @@ template $BookDetail: Box { ] } - Label book_label { - halign: start; - valign: end; - label: _("Book name"); - wrap: true; - wrap-mode: word_char; - ellipsize: end; - max-width-chars: 25; - lines: 4; - xalign: 0; - - styles [ - "title-1", - "bold", - ] - } - - Label author_label { - halign: start; - valign: start; - margin-bottom: 6; - label: _("Book author"); - wrap: true; - wrap-mode: word_char; - ellipsize: end; - max-width-chars: 30; - lines: 2; - xalign: 0; - - styles [ - "title-3", - "dim-label", - ] - } - Box { - halign: center; - valign: center; + orientation: vertical; + margin-top: 12; margin-bottom: 12; - spacing: 5; - - Box download_box { - halign: end; - valign: center; - - Image download_image { - icon-name: 'download-symbolic'; - } - - Label download_label { - margin-start: 5; - margin-end: 4; - label: _("Download"); - } - } + spacing: 6; + vexpand: false; - Switch download_switch { - focusable: true; + Label book_label { halign: start; - valign: center; - } - } - - ProgressBar book_progress_bar { - width-request: 250; - halign: center; - valign: start; - show-text: true; - margin-bottom: 18; - } - - Grid { - margin-bottom: 18; - hexpand: true; - row-spacing: 4; - column-spacing: 20; - - Label remaining_text { - halign: start; - hexpand: true; - label: _("Remaining"); + label: bind template.book_title; + ellipsize: end; + max-width-chars: 35; + lines: 1; styles [ - "dim-label", + "title-1", + "bold", ] - - layout { - column: '0'; - row: '3'; - } } - Label remaining_label { - hexpand: true; - label: 'label'; - xalign: 0; - - layout { - column: '1'; - row: '3'; - } - } - - Label total_label { - hexpand: true; - label: 'label'; - xalign: 0; - - layout { - column: '1'; - row: '2'; - } - } - - Label last_played_label { - hexpand: true; - label: 'label'; - xalign: 0; - - layout { - column: '1'; - row: '1'; - } - } - - Label { + Label author_label { halign: start; - label: _("Total"); + label: _("Book author"); + ellipsize: end; + max-width-chars: 50; + lines: 1; styles [ + "title-3", "dim-label", ] - - layout { - column: '0'; - row: '2'; - } } - Label { - halign: start; - label: _("Last played"); + Box { + valign: end; + margin-top: 18; + spacing: 6; - styles [ - "dim-label", - ] - - layout { - column: '0'; - row: '1'; - } - } - - Label published_text { - halign: start; - label: _("Published"); - - styles [ - "dim-label", - ] - - layout { - column: '0'; - row: '0'; + $ProgressRing book_progress_ring { + valign: center; } - } - Label published_label { - hexpand: true; - label: 'label'; - xalign: 0; - - layout { - column: '1'; - row: '0'; + Grid { + hexpand: true; + row-spacing: 3; + column-spacing: 20; + + Label { + halign: start; + hexpand: true; + label: _("Remaining"); + + styles [ + "dim-label", + ] + + layout { + column: 0; + row: 0; + } + } + + Label remaining_label { + hexpand: true; + xalign: 0; + + layout { + column: 1; + row: 0; + } + } + + Label { + halign: start; + hexpand: true; + label: _("Total"); + + styles [ + "dim-label", + ] + + layout { + column: 0; + row: 1; + } + } + + Label total_label{ + hexpand: true; + xalign: 0; + + layout { + column: 1; + row: 1; + } + } } } - } - - Box unavailable_box { - tooltip-text: _("Some or all files of this book cannot be found."); - halign: center; - spacing: 4; - - Image { - icon-name: 'info-symbolic'; - - styles [ - "unavailable_image", - ] - } - - Label { - label: _("unavailable"); - - styles [ - "unavailable_label", - ] - } - - styles [ - "unavailable_box", - ] - } - Box { - margin-top: 20; - orientation: vertical; - - Button play_book_button { + Button play_button { + halign: start; + valign: end; + vexpand: true; + margin-top: 12; focusable: true; receives-default: true; - halign: center; - icon-name: 'media-playback-start-symbolic'; tooltip-text: _("Start/Stop playback"); + accessibility { label: _("Start or pause the playback"); } @@ -280,81 +194,58 @@ template $BookDetail: Box { "suggested-action", "pill", ] + + Adw.ButtonContent play_icon { + label: _("Start"); + icon-name: 'media-playback-start-symbolic'; + } } } } - }; - } - - FlowBoxChild { - hexpand: true; - vexpand: true; + } - child: Adw.Clamp { - maximum-size: 500; - tightening-threshold: 350; + Adw.Clamp { + maximum-size: 800; + vexpand: true; Stack chapters_stack { + margin-start: 18; + margin-end: 18; + margin-top: 12; + margin-bottom: 24; + StackPage { name: 'chapters_wrapper'; - title: 'page0'; - child: ScrolledWindow book_overview_scroller { - focusable: true; - hexpand: true; - vexpand: true; - hscrollbar-policy: never; - vscrollbar-policy: never; - propagate-natural-width: true; - propagate-natural-height: true; - - child: Viewport track_list_container { - child: Box chapter_box { - valign: start; - margin-start: 8; - margin-end: 8; - margin-top: 8; - margin-bottom: 8; - orientation: vertical; - spacing: 4; - }; - - styles [ - "no_frame", - ] - }; - - styles [ - "no_frame", - ] + child: Box chapter_list_container { + orientation: vertical; + spacing: 18; }; } StackPage { name: 'chapters_loader'; - title: 'chapters'; - child: Box { + child: Spinner spinner { halign: center; - valign: start; - margin-top: 8; - - Spinner { - halign: center; - hexpand: true; - } - - Label chapters_loading_text { - halign: center; - label: _("Loading chapters, please wait..."); - } + spinning: true; }; } } - }; - } - }; + } + }; + } }; + }; + } +} + +menu book_menu_model { + section { + item { + action: 'book_overview.download'; + label: _("_Available Offline"); + hidden-when: "action-disabled"; } } } diff --git a/data/ui/chapter_element.blp b/data/ui/chapter_element.blp index 298c19f1..24c6ac14 100644 --- a/data/ui/chapter_element.blp +++ b/data/ui/chapter_element.blp @@ -1,64 +1,41 @@ using Gtk 4.0; +using Adw 1; -template $ChapterElement: Box { +template $ChapterElement: Adw.ActionRow { + selectable: true; + activatable: true; + use-markup: false; tooltip-text: _("Play this part"); - Box { - margin-start: 12; - margin-end: 12; - margin-top: 6; - margin-bottom: 6; - spacing: 5; + [prefix] + Stack icon_stack { + StackPage { + name: 'number'; - Stack icon_stack { - StackPage { - name: 'number'; + child: Label number_label { + width-chars: 3; - child: Label number_label { - halign: start; - width-chars: 3; - xalign: 0; - yalign: 0; - - styles [ - "dim-label", - ] - }; - } - - StackPage { - name: 'play_icon'; - - child: Image play_icon { - halign: start; - valign: center; - icon-name: 'media-playback-start-symbolic'; - pixel-size: 12; - }; - } + styles [ + "dim-label", + ] + }; } - Label title_label { - hexpand: true; - ellipsize: end; - max-width-chars: 50; - xalign: 0; - yalign: 0; + StackPage { + name: 'icon'; - styles [ - "semi-bold", - ] - } - - Label duration_label { - halign: end; - justify: right; - xalign: 0; - yalign: 0; + child: Image play_icon { + icon-name: 'media-playback-start-symbolic'; + halign: center; + valign: center; + }; } } - styles [ - "chapter_element", - ] + [suffix] + Label duration_label { + styles [ + "dim-label", + ] + } } diff --git a/data/ui/chapter_element.ui b/data/ui/chapter_element.ui new file mode 100644 index 00000000..e69de29b diff --git a/data/ui/main_window.blp b/data/ui/main_window.blp index be97a34c..00decdb6 100644 --- a/data/ui/main_window.blp +++ b/data/ui/main_window.blp @@ -191,13 +191,6 @@ Adw.ApplicationWindow app_window { }; } } - - Adw.NavigationPage { - title: _("Book Title"); - tag: 'book_overview'; - - child: Adw.ToolbarView book_details_container {}; - } }; [bottom] diff --git a/main.py b/main.py index e6129b79..7319e4e7 100755 --- a/main.py +++ b/main.py @@ -94,16 +94,10 @@ def __on_command_line(): ]) -def extend_classes(): - extend_gtk_container() - - def main(): __on_command_line() print(sys.argv) - extend_classes() - listen() application = Application(pkgdatadir) @@ -144,6 +138,5 @@ def listen(): # Some modules import multiprocessing which would lead to an exception # when setting the start method from cozy.application import Application - from cozy.ui.widgets.list_box_extensions import extend_gtk_container main() diff --git a/po/POTFILES b/po/POTFILES index c3520ed5..2a1de74e 100644 --- a/po/POTFILES +++ b/po/POTFILES @@ -11,7 +11,6 @@ cozy/ui/book_detail_view.py cozy/ui/chapter_element.py cozy/ui/db_migration_failed_view.py cozy/ui/delete_book_view.py -cozy/ui/disk_element.py cozy/ui/file_not_found_dialog.py cozy/ui/import_failed_dialog.py cozy/ui/main_view.py