239 lines
9.5 KiB
Python
239 lines
9.5 KiB
Python
# 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 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)
|
|
savedtime = GObject.Property(type=float)
|
|
|
|
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__()
|
|
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 __get_current_playtime(self) -> float:
|
|
if not self._playbin.clock:
|
|
return 0.0
|
|
time = self._playbin.clock.get_time() - self._playbin.base_time
|
|
return time / Gst.SECOND
|
|
|
|
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
|
|
case (_, Gst.State.PAUSED, Gst.State.VOID_PENDING):
|
|
print("audio: state changed to 'paused'")
|
|
self.status = "Paused"
|
|
self.playing = False
|
|
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.__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", "savedtime"]:
|
|
self.set_property(tag, 0)
|
|
|
|
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.__get_current_playtime() + self.savedtime
|
|
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.savedtime += self.__get_current_playtime()
|
|
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
|