diff --git a/cozy/app_controller.py b/cozy/app_controller.py index 1d9d13cb..91296a06 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 0274a982..26bca729 100644 --- a/cozy/ui/chapter_element.py +++ b/cozy/ui/chapter_element.py @@ -1,6 +1,6 @@ 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 @@ -21,8 +21,8 @@ def __init__(self, chapter: Chapter): self.connect("activated", self._on_button_press) self.set_title(self.chapter.name) - self.duration_label.set_label(seconds_to_str(self.chapter.length)) self.number_label.set_text(str(self.chapter.number)) + 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 35b846eb..e5313123 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