From 35d0d815cad412c6f4432d47061d0611dbb41997 Mon Sep 17 00:00:00 2001 From: Anna Schumaker Date: Thu, 16 Jun 2022 16:25:20 -0400 Subject: [PATCH] audio: Load a track requested by the user Either through the command line, mpris2, or the open button in the header. Implements: #7 (Add MPRIS2 Support) Implements: #47 (Signal that the track has changed when it actually changes) Signed-off-by: Anna Schumaker --- emmental/__init__.py | 22 ++++++++++++++++- emmental/audio/__init__.py | 44 ++++++++++++++++++++++++++++++++++ emmental/mpris2/player.py | 6 +++++ emmental/path.py | 11 +++++++++ tests/audio/test_audio.py | 46 ++++++++++++++++++++++++++++++++++++ tests/mpris2/test_player.py | 5 ++++ tests/test_path.py | 21 ++++++++++++++++ tests/util/__init__.py | 4 ++++ tests/util/track.ogg | Bin 0 -> 5682 bytes 9 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 emmental/path.py create mode 100644 tests/test_path.py create mode 100644 tests/util/track.ogg diff --git a/emmental/__init__.py b/emmental/__init__.py index f33b34a..e1848d3 100644 --- a/emmental/__init__.py +++ b/emmental/__init__.py @@ -1,5 +1,6 @@ # Copyright 2022 (c) Anna Schumaker. """Set up our Application.""" +import pathlib from . import gsetup from . import audio from . import db @@ -10,6 +11,8 @@ from . import options from . import window from gi.repository import GObject from gi.repository import GLib +from gi.repository import Gio +from gi.repository import Gst from gi.repository import Adw MAJOR_VERSION = 3 @@ -28,9 +31,18 @@ class Application(Adw.Application): def __init__(self): """Initialize an Application.""" super().__init__(application_id=gsetup.APPLICATION_ID, - resource_base_path=gsetup.RESOURCE_PATH) + resource_base_path=gsetup.RESOURCE_PATH, + flags=Gio.ApplicationFlags.HANDLES_OPEN) self.add_main_option_entries([options.Version]) + def __load_file(self, file: pathlib.Path) -> None: + self.player.set_state_sync(Gst.State.READY) + self.player.file = file + self.player.set_state_sync(Gst.State.PLAYING) + + def __load_path(self, src: GObject.GObject, path: pathlib.Path) -> None: + self.__load_file(path) + def __set_replaygain(self, *args) -> None: enabled = self.db.settings["audio.replaygain.enabled"] mode = self.db.settings["audio.replaygain.mode"] @@ -48,6 +60,7 @@ class Application(Adw.Application): hdr.connect("notify::rg-enabled", self.__set_replaygain) hdr.connect("notify::rg-mode", self.__set_replaygain) + hdr.connect("track-requested", self.__load_path) self.__set_replaygain() return hdr @@ -75,6 +88,7 @@ class Application(Adw.Application): self.mpris.app.connect("Quit", self.win.close) self.mpris.player.link_property("Volume", self.win.header, "volume") + self.mpris.player.connect("OpenPath", self.__load_path) def do_handle_local_options(self, opts: GLib.VariantDict) -> int: """Handle any command line options.""" @@ -103,6 +117,12 @@ class Application(Adw.Application): Adw.Application.do_activate(self) self.win.present() + def do_open(self, files: list, n_files: int, hint: str) -> None: + """Play an audio file passed from the command line.""" + if n_files > 0: + self.__load_file(pathlib.Path(files[0].get_path())) + self.activate() + def do_shutdown(self) -> None: """Handle the Adw.Application::shutdown signal.""" Adw.Application.do_shutdown(self) diff --git a/emmental/audio/__init__.py b/emmental/audio/__init__.py index e68376b..489e2ac 100644 --- a/emmental/audio/__init__.py +++ b/emmental/audio/__init__.py @@ -1,8 +1,10 @@ # Copyright 2022 (c) Anna Schumaker. """A custom GObject managing a GStreamer playbin.""" +import pathlib from gi.repository import GObject from gi.repository import Gst from . import replaygain +from .. import path class Player(GObject.GObject): @@ -10,6 +12,9 @@ class Player(GObject.GObject): volume = GObject.Property(type=float, default=1.0) + file = GObject.Property(type=GObject.TYPE_PYOBJECT) + have_track = GObject.Property(type=bool, default=False) + def __init__(self): """Initialize the audio Player.""" super().__init__() @@ -21,8 +26,28 @@ class Player(GObject.GObject): Gst.ElementFactory.make("fakesink")) self._playbin.set_state(Gst.State.READY) + bus = self._playbin.get_bus() + bus.add_signal_watch() + bus.connect("message::eos", self.__msg_eos) + bus.connect("message::stream-start", self.__msg_stream_start) + self.bind_property("volume", self._playbin, "volume") + self.connect("notify::file", self.__notify_file) + + def __msg_eos(self, bus: Gst.Bus, message: Gst.Message) -> None: + self.emit("eos") + + 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"))) + + def __notify_file(self, player: GObject.GObject, param) -> None: + if self.file: + uri = self.file.as_uri() + print(f"audio: loading {uri}") + self._playbin.set_property("uri", uri) + def get_replaygain(self) -> tuple[bool, str | None]: """Get the current ReplayGain mode.""" mode = self._replaygain.mode @@ -36,6 +61,25 @@ class Player(GObject.GObject): """Set the ReplayGain mode.""" self._replaygain.mode = mode if enabled else "disabled" + def set_state_sync(self, state: Gst.State) -> None: + """Set the state of the playbin, and wait for it to change.""" + if self._playbin.set_state(state) == Gst.StateChangeReturn.ASYNC: + self.get_state() + def shutdown(self) -> None: """Shut down the player.""" self._playbin.set_state(Gst.State.NULL) + + @GObject.Signal(arg_types=(GObject.TYPE_PYOBJECT,)) + def file_loaded(self, file: pathlib.Path) -> None: + """Signal that a new URI has started.""" + print("audio: file loaded") + self.have_track = True + + @GObject.Signal + def eos(self) -> None: + """Signal that the current track has ended.""" + print("audio: end of stream") + self.set_state_sync(Gst.State.READY) + self.have_track = False + self.file = None diff --git a/emmental/mpris2/player.py b/emmental/mpris2/player.py index a185934..6b5348e 100644 --- a/emmental/mpris2/player.py +++ b/emmental/mpris2/player.py @@ -2,6 +2,7 @@ """Our Mpris2 Player dbus Object.""" import pathlib from gi.repository import GObject +from .. import path from . import dbus PLAYER_XML = pathlib.Path(__file__).parent / "Player.xml" @@ -69,3 +70,8 @@ class Player(dbus.Object): @GObject.Signal(arg_types=(str,)) def OpenUri(self, uri: str) -> None: """Open the given uri.""" + self.emit("OpenPath", path.from_uri(uri)) + + @GObject.Signal(arg_types=(GObject.TYPE_PYOBJECT,)) + def OpenPath(self, filepath: pathlib.Path) -> None: + """Open the given path.""" diff --git a/emmental/path.py b/emmental/path.py new file mode 100644 index 0000000..304d4be --- /dev/null +++ b/emmental/path.py @@ -0,0 +1,11 @@ +# Copyright 2022 (c) Anna Schumaker. +"""Extra path handling for URIs.""" +import pathlib +import urllib + + +def from_uri(uri: str) -> pathlib.Path: + """Make a path from a uri.""" + if parsed := urllib.parse.urlparse(uri): + return pathlib.Path(urllib.parse.unquote(parsed.path)) + return pathlib.Path(uri) diff --git a/tests/audio/test_audio.py b/tests/audio/test_audio.py index 895cf45..a71644a 100644 --- a/tests/audio/test_audio.py +++ b/tests/audio/test_audio.py @@ -4,7 +4,9 @@ import io import unittest import unittest.mock import emmental.audio +import tests.util from gi.repository import GObject +from gi.repository import GLib from gi.repository import Gst @@ -15,11 +17,23 @@ class TestAudio(unittest.TestCase): """Set up common variables.""" self.player = emmental.audio.Player() + def tearDown(self): + """Clean up the playbin.""" + self.player.shutdown() + + def main_loop(self) -> None: + """Run a GLib main loop.""" + while GLib.main_context_default().iteration(): + pass + def test_player(self): """Test that the audio player was set up correctly.""" self.assertIsInstance(self.player, GObject.GObject) self.assertEqual(self.player.get_state(), Gst.State.READY) + self.assertIsNone(self.player.file) + self.assertFalse(self.player.have_track) + def test_playbin(self): """Test that the playbin was configured correctly.""" self.assertIsInstance(self.player._playbin, Gst.Element) @@ -30,6 +44,38 @@ class TestAudio(unittest.TestCase): self.assertRegex(self.player._playbin.get_property("video-sink").name, r"fakesink\d+") + @unittest.mock.patch("sys.stdout", new_callable=io.StringIO) + def test_eos(self, mock_stdout: io.StringIO): + """Test handling an EOS message.""" + self.player.file = tests.util.TRACK_OGG + + eos = Gst.Message.new_eos(self.player._playbin) + self.player._playbin.get_bus().post(eos) + self.main_loop() + + self.assertRegex(mock_stdout.getvalue(), "audio: end of stream") + self.assertEqual(self.player.get_state(), Gst.State.READY) + self.assertFalse(self.player.have_track) + self.assertIsNone(self.player.file) + + @unittest.mock.patch("sys.stdout", new_callable=io.StringIO) + def test_file(self, mock_stdout: io.StringIO): + """Test that the file property works as expected.""" + self.player.file = tests.util.TRACK_OGG + self.assertEqual(self.player._playbin.get_property("uri"), + tests.util.TRACK_OGG.as_uri()) + + started = unittest.mock.Mock() + self.player.connect("file-loaded", started) + self.player.set_state_sync(Gst.State.PAUSED) + self.main_loop() + started.assert_called_with(self.player, tests.util.TRACK_OGG) + self.assertTrue(self.player.have_track) + + self.assertEqual(mock_stdout.getvalue(), + f"audio: loading {tests.util.TRACK_OGG.as_uri()}\n" + "audio: file loaded\n") + def test_volume(self): """Test that the volume property works as expected.""" self.assertEqual(self.player.volume, 1.0) diff --git a/tests/mpris2/test_player.py b/tests/mpris2/test_player.py index ab2af9c..e573d7a 100644 --- a/tests/mpris2/test_player.py +++ b/tests/mpris2/test_player.py @@ -2,6 +2,7 @@ """Tests our Mpris Player object.""" import pathlib import unittest +import unittest.mock import emmental.mpris2.player from gi.repository import Gio @@ -55,4 +56,8 @@ class TestPlayer(unittest.TestCase): self.player.emit("Seek", 12345) self.player.emit("SetPosition", "/com/nowheycreamery/emmental/123", 12345) + + open_path = unittest.mock.Mock() + self.player.connect("OpenPath", open_path) self.player.emit("OpenUri", "/a/b/c.ogg") + open_path.assert_called_with(self.player, pathlib.Path("/a/b/c.ogg")) diff --git a/tests/test_path.py b/tests/test_path.py new file mode 100644 index 0000000..bea53a2 --- /dev/null +++ b/tests/test_path.py @@ -0,0 +1,21 @@ +# Copyright 2022 (c) Anna Schumaker. +"""Tests our custom Path class.""" +import pathlib +import unittest +import emmental.path + + +class TestPath(unittest.TestCase): + """Test our path module.""" + + def test_from_uri(self): + """Test getting a path from a uri.""" + p = emmental.path.from_uri("file:///a/b/c.txt") + self.assertIsInstance(p, pathlib.Path) + self.assertEqual(str(p), "/a/b/c.txt") + + p = emmental.path.from_uri("file:///a/b%20c/d.txt") + self.assertEqual(str(p), "/a/b c/d.txt") + + p = emmental.path.from_uri("/a/b/c.txt") + self.assertEqual(str(p), "/a/b/c.txt") diff --git a/tests/util/__init__.py b/tests/util/__init__.py index a2ba0d6..b5061f2 100644 --- a/tests/util/__init__.py +++ b/tests/util/__init__.py @@ -1,10 +1,14 @@ # Copyright 2022 (c) Anna Schumaker """Helper utilities for testing.""" +import pathlib import unittest import emmental.db from gi.repository import GObject +TRACK_OGG = pathlib.Path(__file__).parent / "track.ogg" + + class TestCase(unittest.TestCase): """A TestCase that handles database setup and cleanup.""" diff --git a/tests/util/track.ogg b/tests/util/track.ogg new file mode 100644 index 0000000000000000000000000000000000000000..c739cddc9724ea22d90d99015b6ab5fb15423893 GIT binary patch literal 5682 zcmeHLYgAKL7Cs=4@F)!sFwp2gn@ED_A{dmYbVwiqffTsm@(7bs9;wh6s)Sgb8M=@J zq!C#(hM+Wdz!D1zwy}cZEM3+CM4=R<0Vy(Qses!0>KbsZ&YGEh6VUpr_J_ZwXPw-; z?|yyf?6c3gJ2z?TRxz?ii>>NS)=vak_o(`q-AcQi?-u74DhY;NOdD}Ss4T+HdOu^A zL|8s)ge4)d{P|41*MFhiAuda8&oY}UchE@8+4=VR(6#HG7JS)9|;BL!RDV`A#i~ySg z;mWG8Xhnz!-d0&r@uK@!f_w>hCB->;@*AMxI}&%zNfJr$iPR78Z95M4Cgyw^{t4w3pK?dGjfaYcXnFe(m7nJGYami{Y?i@{XF@&it?+!iVt8oZtR+oPt z+V_pxjbGKEjt_tAui47mo6=0s0U2qmYu3wBVPt#0Sub~bZqtn3#AD03F2hGdJwPS( z-u|bN{6xj7XALsUH;P=jKj%7&bal2MV2fw2f7or_!d$3}zuL&ZGdx$&tlvmt)a}58 zLAHR~qbO2f1qF*+y5v>`R#J5p#6s!RFS^v(Iz=cZHtJqu>0V{&rXyMKWoID6vmuXQ(Tg43bgpvM-|V==k_1Hu~JGIt`nw12Sag5r?=s~p=LhrJ^qpy?&H zLt}9P5OTl#Ii-J?Ch}rfur;`NjRg}}#Q3i@(FjN64S|J1%nl7nlmqScHN3gD`aWiJ zpUhL)@4ern7cHXF`5lu!A3;$;lTWauB+) zohCZK5m6W-Z$T18I_52~)N#Jzgr}t9B8te8if8(Ya05fMP9&n>Vs8QNgNqF!yhHXi zM=_p?Px|7PPFdD`X}Uo*xlS}rJc3jOkJ3)XWc?FY_*g1Fu^y!1DJ9YrT$Tx*cSJ{B{q65w?*0^1k;^Ph)EC)S@m zFFL52oO>nu=7vhn$SDQ*n=FxL*Q+KA?w(Cf#ii-8@dZ46z7z&=bG~$nijOTo$SW;| z!#NA3rh9I6cxMHSVD_Lwn(_Yh+`rFoQ8mWsL|zp|4Mzb>Q;T&|H3M9hpAzG zT!BfpwObk_`@%I>B}`LCvT$Fk$v;aTzNMPiXFQgoz9jH9FW_r_Tk!HOaQFo`XuJsYfT=#xP}MC2~8d${GSZ$O>-iz%Y58 z5(8H<4`DF45(?QcDsbh&7lJFX5Jr`|nq|olzGDVg3GC5iY##^`gluqCWWF`ZD_GO) zmB(cC`sSTz@9>Xi*}yHndF|&2a7M3AxSs&u-v!6v8|GULlD4j%YjQ=`zZ#T7tsN3^ zViz&0F2_vey>sdzn0ZgKu)4iNl2y&!y9loB09SJJJP5#5f-t<3Dz6EjZ;%`Zf&3lw zOoQ@1d*+&09)6lC2Mh_gniTfJ;CNW2-vj&Jp4j&efkL)Z6R(Or+sq-cXN-C+ zdp4jMW6#huSlDcUUWXAB=4xuzVe%$DhP~4c``(_|_pfjmzRs|18K~A3y|taz|7ddx z2Or-Va`~s2VGiu96;=-HK1IKcqf$*;b#Rqbx)75R4haki9tl>!$(%18Nwso1E3!Fw zI&ciKsSGKh)2Yg$rdbG%B5Ols)|hH?p(2O7@pRGMv-f|#_;AxDgdR9JqelBz(qbc6 z{%#@GQ9mTtE3O%6}eweyeA3Dr1yGK zn?0@4o}nDk}m_>b2bS>}kSroh#9W#IGz)pW4feWMF2yqfFOq}M~Fa;n)RQ}Z{H zggPZz7~k4)Ew|06PR4}oJ^p!+Q=^hL^Jv2+$Xj1|8}}MbSh%J)LpgYEp%#;~M_eC~Xw%^3%w%9zo(G@A$Y0`_ z^wl;!7n5GMs0VdK*lZ)%0uK{fZAk=m`w5RlRJK+ri}8dqX~;q*p$D-g^Wm}>Ew^q$-scsP^k^HZ|X@)=z$kI*#VIt1ffyMq05lF2l;hpUQpEbQ&8RV^DE;x zAr5<;>yRhg2hmqCzqgO7=n;I|<5=Q%qkQeXjbuK#oZS5#a*ez2rOSrR4=aPN-*7m5 z1ar22EP&O28KFy#WY1yRmdakI*u9sW