diff --git a/emmental/__init__.py b/emmental/__init__.py index f7fcd79..a6c0c78 100644 --- a/emmental/__init__.py +++ b/emmental/__init__.py @@ -44,6 +44,13 @@ class Application(Adw.Application): def __load_path(self, src: GObject.GObject, path: pathlib.Path) -> None: self.__load_file(path) + def __seek(self, player: mpris2.player.Player, offset: float) -> None: + self.player.seek(self.player.position + offset) + + def __set_position(self, player: mpris2.player.Player, + trackid: str, position: float) -> None: + self.player.seek(position) + def __set_replaygain(self, *args) -> None: enabled = self.db.settings["audio.replaygain.enabled"] mode = self.db.settings["audio.replaygain.mode"] @@ -104,13 +111,17 @@ class Application(Adw.Application): self.player.bind_property(tag, self.mpris.player, tag) for (prop, mpris_prop) in [("have-track", "CanPlay"), ("have-track", "CanPause"), - ("status", "PlaybackStatus")]: + ("have-track", "CanSeek"), + ("status", "PlaybackStatus"), + ("position", "Position")]: self.player.bind_property(prop, self.mpris.player, mpris_prop) self.mpris.player.link_property("Volume", self.win.header, "volume") self.mpris.player.connect("OpenPath", self.__load_path) self.mpris.player.connect("Pause", self.player.pause) self.mpris.player.connect("Play", self.player.play) self.mpris.player.connect("PlayPause", self.player.play_pause) + self.mpris.player.connect("Seek", self.__seek) + self.mpris.player.connect("SetPosition", self.__set_position) self.mpris.player.connect("Stop", self.player.stop) def do_handle_local_options(self, opts: GLib.VariantDict) -> int: diff --git a/emmental/audio/__init__.py b/emmental/audio/__init__.py index 051b451..2cdfdab 100644 --- a/emmental/audio/__init__.py +++ b/emmental/audio/__init__.py @@ -2,11 +2,16 @@ """A custom GObject managing a GStreamer playbin.""" import pathlib from gi.repository import GObject +from gi.repository import GLib from gi.repository import Gst from . import replaygain from .. import path +UPDATE_INTERVAL = 100 +SEEK_FLAGS = Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT + + class Player(GObject.GObject): """Wraps a GStreamer Playbin with an interface for our application.""" @@ -16,6 +21,7 @@ class Player(GObject.GObject): album_disc_number = GObject.Property(type=int) title = GObject.Property(type=str) track_number = GObject.Property(type=int) + position = GObject.Property(type=float, default=0) duration = GObject.Property(type=float, default=0) volume = GObject.Property(type=float, default=1.0) @@ -28,6 +34,7 @@ class Player(GObject.GObject): """Initialize the audio Player.""" super().__init__() self._replaygain = replaygain.Filter() + self._timeout = None self._playbin = Gst.ElementFactory.make("playbin") self._playbin.set_property("audio-filter", self._replaygain) @@ -37,6 +44,7 @@ class Player(GObject.GObject): bus = self._playbin.get_bus() bus.add_signal_watch() + bus.connect("message::async-done", self.__msg_async_done) bus.connect("message::eos", self.__msg_eos) bus.connect("message::state-changed", self.__msg_state_changed) bus.connect("message::stream-start", self.__msg_stream_start) @@ -46,6 +54,9 @@ class Player(GObject.GObject): self.connect("notify::file", self.__notify_file) + def __msg_async_done(self, bus: Gst.Bus, message: Gst.Message) -> None: + self.__update_position() + def __msg_eos(self, bus: Gst.Bus, message: Gst.Message) -> None: self.emit("eos") @@ -72,6 +83,8 @@ class Player(GObject.GObject): self.status = "Stopped" self.playing = False + self.__update_timeout() + def __msg_stream_start(self, bus: Gst.Bus, message: Gst.Message) -> None: self.emit("file-loaded", path.from_uri(self._playbin.get_property("current-uri"))) @@ -97,10 +110,23 @@ class Player(GObject.GObject): def __reset_properties(self, *, duration: float = 0.0) -> None: for tag in ["artist", "album-artist", "album", "title"]: self.set_property(tag, "") - for tag in ["album-disc-number", "track-number"]: + for tag in ["album-disc-number", "track-number", "position"]: self.set_property(tag, 0) self.duration = duration + def __update_position(self) -> bool: + (res, pos) = self._playbin.query_position(Gst.Format.TIME) + self.position = pos / Gst.USECOND if res else 0 + return GLib.SOURCE_CONTINUE + + def __update_timeout(self) -> None: + if self.playing and self._timeout is None: + self._timeout = GLib.timeout_add(UPDATE_INTERVAL, + self.__update_position) + elif self.playing is False and self._timeout is not None: + GLib.source_remove(self._timeout) + self._timeout = None + def get_replaygain(self) -> tuple[bool, str | None]: """Get the current ReplayGain mode.""" mode = self._replaygain.mode @@ -123,6 +149,11 @@ class Player(GObject.GObject): state = Gst.State.PAUSED if self.playing else Gst.State.PLAYING self.set_state_sync(state) + def seek(self, newpos: float, *args) -> None: + """Seek to a different point in the stream.""" + self._playbin.seek_simple(Gst.Format.TIME, SEEK_FLAGS, + newpos * Gst.USECOND) + def set_replaygain(self, enabled: bool, mode: str) -> None: """Set the ReplayGain mode.""" self._replaygain.mode = mode if enabled else "disabled" diff --git a/emmental/mpris2/player.py b/emmental/mpris2/player.py index 58a6220..9d287cf 100644 --- a/emmental/mpris2/player.py +++ b/emmental/mpris2/player.py @@ -53,7 +53,7 @@ class Player(dbus.Object): case "PlaybackStatus": changed = GLib.Variant("s", self.PlaybackStatus) self.properties_changed({property: changed}) - case "CanPlay" | "CanPause": + case "CanPlay" | "CanPause" | "CanSeek": changed = GLib.Variant("b", self.get_property(property)) self.properties_changed({property: changed}) diff --git a/tests/audio/test_audio.py b/tests/audio/test_audio.py index ece3be1..ab316a9 100644 --- a/tests/audio/test_audio.py +++ b/tests/audio/test_audio.py @@ -26,6 +26,12 @@ class TestAudio(unittest.TestCase): while GLib.main_context_default().iteration(): pass + def test_constants(self): + """Test audio player constants.""" + self.assertEqual(emmental.audio.UPDATE_INTERVAL, 100) + self.assertEqual(emmental.audio.SEEK_FLAGS, + Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT) + def test_player(self): """Test that the audio player was set up correctly.""" self.assertIsInstance(self.player, GObject.GObject) @@ -40,6 +46,7 @@ class TestAudio(unittest.TestCase): self.assertIsInstance(self.player._playbin, Gst.Element) self.assertIsInstance(self.player._playbin.get_property("video-sink"), Gst.Element) + self.assertIsNone(self.player._timeout, None) self.assertRegex(self.player._playbin.name, r"playbin\d+") self.assertRegex(self.player._playbin.get_property("video-sink").name, @@ -50,6 +57,7 @@ class TestAudio(unittest.TestCase): """Test handling an EOS message.""" self.player.file = tests.util.TRACK_OGG self.player.duration = 10 + self.player.position = 8 eos = Gst.Message.new_eos(self.player._playbin) self.player._playbin.get_bus().post(eos) @@ -59,7 +67,8 @@ class TestAudio(unittest.TestCase): for prop in ["artist", "album-artist", "album", "title"]: self.assertEqual(self.player.get_property(prop), "") - for prop in ["album-disc-number", "track-number", "duration"]: + for prop in ["album-disc-number", "track-number", + "position", "duration"]: self.assertEqual(self.player.get_property(prop), 0) self.assertEqual(self.player.get_state(), Gst.State.READY) @@ -197,6 +206,23 @@ class TestAudio(unittest.TestCase): self.main_loop() self.assertEqual(self.player.duration, 10 * Gst.SECOND / Gst.USECOND) + @unittest.mock.patch("sys.stdout", new_callable=io.StringIO) + def test_position(self, mock_stdout: io.StringIO): + """Test the position property.""" + self.assertEqual(self.player.position, 0) + + query_position = unittest.mock.Mock(return_value=(True, Gst.SECOND)) + self.player._playbin.query_position = query_position + self.player._Player__update_position() + self.assertEqual(self.player.position, Gst.SECOND / Gst.USECOND) + + seek_simple = unittest.mock.Mock() + self.player._playbin.seek_simple = seek_simple + self.player.seek(5 * Gst.SECOND / Gst.USECOND) + seek_simple.assert_called_with(Gst.Format.TIME, + emmental.audio.SEEK_FLAGS, + 5 * Gst.SECOND) + def test_volume(self): """Test that the volume property works as expected.""" self.assertEqual(self.player.volume, 1.0)