# Copyright 2022 (c) Anna Schumaker. """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 filter from . import stopwatch from .. import path from .. import tmpdir 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.""" artist = GObject.Property(type=str) album = GObject.Property(type=str) album_artist = GObject.Property(type=str) 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) artwork = GObject.Property(type=GObject.TYPE_PYOBJECT) file = GObject.Property(type=GObject.TYPE_PYOBJECT) playing = GObject.Property(type=bool, default=False) status = GObject.Property(type=str, default="Stopped") have_track = GObject.Property(type=bool, default=False) almost_done = GObject.Property(type=bool, default=False) playtime = GObject.Property(type=float) stopwatch = GObject.Property(type=stopwatch.StopWatch) bg_enabled = GObject.Property(type=bool, default=False) bg_volume = GObject.Property(type=float, default=0.5) pause_on_load = GObject.Property(type=bool, default=False) def __init__(self): """Initialize the audio Player.""" super().__init__(stopwatch=stopwatch.StopWatch()) self._filter = filter.Filter() self._timeout = None self._playbin = Gst.ElementFactory.make("playbin") self._playbin.set_property("audio-filter", self._filter) self._playbin.set_property("video-sink", Gst.ElementFactory.make("fakesink")) self._playbin.set_state(Gst.State.READY) 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) bus.connect("message::tag", self.__msg_tags) self.bind_property("volume", self._playbin, "volume") self.bind_property("bg-enabled", self._filter, "bg-enabled") self.bind_property("bg-volume", self._filter, "bg-volume") self.connect("notify::file", self.__notify_file) def __check_last_second(self) -> None: if self.duration - self.position <= 2 * (Gst.SECOND / Gst.USECOND): if not self.almost_done: self.emit("about-to-finish") 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") def __msg_state_changed(self, bus: Gst.Bus, message: Gst.Message) -> None: if message.src == self._playbin: (old, new, pending) = message.parse_state_changed() match (self.status, new, pending): case ("Playing", Gst.State.PLAYING, _) | \ ("Paused", Gst.State.PAUSED, _) | \ ("Stopped", Gst.State.READY, _) | \ ("Stopped", Gst.State.NULL, _): pass case (_, Gst.State.PLAYING, Gst.State.VOID_PENDING): print("audio: state changed to 'playing'") self.status = "Playing" self.playing = True self.stopwatch.start() case (_, Gst.State.PAUSED, Gst.State.VOID_PENDING): print("audio: state changed to 'paused'") self.status = "Paused" self.playing = False self.stopwatch.stop() case (_, Gst.State.READY, Gst.State.VOID_PENDING) | \ (_, Gst.State.NULL, Gst.State.VOID_PENDING): print("audio: state changed to 'stopped'") self.status = "Stopped" self.playing = False self.stopwatch.stop() 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"))) def __msg_tags(self, bus: Gst.Bus, message: Gst.Message) -> None: taglist = message.parse_tag() for tag in ["artist", "album", "album-artist", "album-disc-number", "title", "track-number", "artwork"]: match tag: case "artwork": (res, sample) = taglist.get_sample("image") if res: buffer = sample.get_buffer() (res, map) = buffer.map(Gst.MapFlags.READ) if res: value = tmpdir.cover_jpg(map.data) buffer.unmap(map) case "track-number" | "album-disc-number": (res, value) = taglist.get_uint(tag) case _: (res, value) = taglist.get_string(tag) if res and self.get_property(tag) != value: self.set_property(tag, value) 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 __reset_properties(self, *, duration: float = 0.0, artwork: pathlib.Path | None = None) -> None: for tag in ["artist", "album-artist", "album", "title"]: self.set_property(tag, "") for tag in ["album-disc-number", "track-number", "position", "playtime"]: self.set_property(tag, 0) self.stopwatch.reset() self.almost_done = False self.pause_on_load = False self.artwork = artwork 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 self.playtime = self.stopwatch.elapsed_time() self.__check_last_second() 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._filter.rg_mode return (False, None) if mode == "disabled" else (True, mode) def get_state(self) -> Gst.State: """Get the current state of the Player.""" return self._playbin.get_state(Gst.CLOCK_TIME_NONE).state def pause(self, *args) -> None: """Pause playback.""" self.set_state_sync(Gst.State.PAUSED) def play(self, *args) -> None: """Start playback.""" self.set_state_sync(Gst.State.PLAYING) def play_pause(self, *args) -> None: """Start or Pause playback.""" 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._filter.rg_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) def stop(self, *args) -> None: """Stop playback.""" self.set_state_sync(Gst.State.READY) @GObject.Signal def about_to_finish(self) -> None: """Signal that playback is almost done.""" print("audio: about to finish") self.almost_done = True @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") if self.pause_on_load: self._playbin.set_state(Gst.State.PAUSED) (res, dur) = self._playbin.query_duration(Gst.Format.TIME) cover = self.file.parent / "cover.jpg" self.__reset_properties(duration=(dur / Gst.USECOND if res else 0), artwork=(cover if cover.is_file() else None)) 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.__reset_properties() self.have_track = False self.file = None