audio: Add a 'position' property to the player

And schedule a repeating callback to update the UI.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
This commit is contained in:
Anna Schumaker 2022-07-13 11:23:35 -04:00
parent a5db116d42
commit e0becbb059
4 changed files with 72 additions and 4 deletions

View File

@ -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:

View File

@ -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"

View File

@ -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})

View File

@ -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)