diff --git a/.github/workflows/flatpak.yml b/.github/workflows/flatpak.yml index 8cfe211b..ec0f9d42 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: @@ -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 ac7309dc..f6d7fa67 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 abd3b723..67dfed58 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 @@ -18,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 @@ -51,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) @@ -116,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/control/mpris.py b/cozy/control/mpris.py index 99b5d5bf..f51836cd 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.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 66eb685e..5bec8fb4 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 import inject -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.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 5ca74fc7..44b6c572 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 83bbe359..fbc9ce77 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/app_view.py b/cozy/ui/app_view.py index 3a2911da..8faac506 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 d3412bb9..2c242383 100644 --- a/cozy/ui/book_detail_view.py +++ b/cozy/ui/book_detail_view.py @@ -1,161 +1,204 @@ 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 import inject -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.model.book import Book 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 51d1d7ff..26bca729 100644 --- a/cozy/ui/chapter_element.py +++ b/cozy/ui/chapter_element.py @@ -1,37 +1,28 @@ -from gi.repository import Gdk, GObject, Gtk +from gi.repository import Adw, 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 -@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(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, *_): ... @@ -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 8a3f8ed8..a8815cfd 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 85169021..aed813d8 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/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/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 aff20f84..deef99d7 100644 --- a/cozy/view_model/book_detail_view_model.py +++ b/cozy/view_model/book_detail_view_model.py @@ -1,6 +1,6 @@ import inject -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 @@ -80,14 +80,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: @@ -95,7 +95,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: @@ -156,9 +156,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 @@ -166,8 +163,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: @@ -183,24 +179,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/cozy/view_model/playback_control_view_model.py b/cozy/view_model/playback_control_view_model.py index 9b2dce5a..fcb76a6e 100644 --- a/cozy/view_model/playback_control_view_model.py +++ b/cozy/view_model/playback_control_view_model.py @@ -1,4 +1,5 @@ import inject +from gi.repository import Gst from cozy.architecture.event_sender import EventSender from cozy.architecture.observable import Observable @@ -6,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) @@ -45,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: @@ -68,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: @@ -76,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/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 0ae6f8b2..7319e4e7 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 @@ -93,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) @@ -143,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 diff --git a/test/cozy/media/test_player.py b/test/cozy/media/test_player.py index 9e25c301..49bb2de6 100644 --- a/test/cozy/media/test_player.py +++ b/test/cozy/media/test_player.py @@ -5,7 +5,7 @@ from peewee import SqliteDatabase from cozy.application_settings import ApplicationSettings -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 27f61af0..30a01394 100644 --- a/test/cozy/model/test_book.py +++ b/test/cozy/model/test_book.py @@ -292,10 +292,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 c8dd9518..266e38fd 100644 --- a/test/cozy/model/test_database_importer.py +++ b/test/cozy/model/test_database_importer.py @@ -518,7 +518,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 @@ -533,10 +533,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 @@ -550,7 +550,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