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:
parent
a5db116d42
commit
e0becbb059
|
@ -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:
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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})
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue