An implementation of MPRIS2 to replace keybinder and change some getter/setter to properties

This commit is contained in:
Valentijn 2018-08-16 21:07:06 +02:00
parent 890e51bced
commit 91681acb5b
16 changed files with 519 additions and 197 deletions

View file

@ -47,29 +47,11 @@ def main():
parser.add_argument("-v", "--version", action=MultilineVersionAction)
keybinds_group = parser.add_mutually_exclusive_group()
keybinds_group.add_argument(
"--with-x-keybinds",
help="define global X keybinds (requires Keybinder and PyGObject)",
action='store_true'
)
keybinds_group.add_argument(
"--without-x-keybinds",
help="Don't define global keybinds (overrides configuration file)",
action='store_true'
)
args = parser.parse_args()
if args.version:
exit(0)
if (args.with_x_keybinds or settings_manager.get('x_keybinds', 'clay_settings')) \
and not args.without_x_keybinds:
player.enable_xorg_bindings()
urwid.main()

View file

@ -3,3 +3,4 @@ from .gp import gp
from .log import logger
from .settings import settings_manager
from .osd import osd_manager
from .mpris2 import MPRIS2

View file

@ -2,11 +2,6 @@
hotkeys:
mod_key: ctrl
x_hotkeys:
play_pause: XF86AudioPlay
next: XF86AudioNext
prev: XF86AudioPrev
clay_hotkeys:
global:
seek_start: mod + q
@ -60,7 +55,6 @@ hotkeys:
equalizer_down: "-"
clay_settings:
x_keybinds: false
unicode: true
play_settings:

View file

@ -131,7 +131,7 @@ class Track(object):
self.artist_art_filename = sha1(
self.artist_art_url.encode('utf-8')
).hexdigest() + u'.jpg'
self.explicit_rating = (int(data['explicitType']))
self.explicit_rating = (int(data['explicitType'] if 'explicitType' in data else 0))
if self.rating == 5:
gp.cached_liked_songs.add_liked_song(self)

View file

@ -0,0 +1,42 @@
<node>
<interface name="org.mpris.MediaPlayer2.Player">
<property name="PlaybackStatus" type="s" access="read"/>
<property name="LoopStatus" type="s" access="readwrite"/>
<property name="Shuffle" type="b" access="readwrite"/>
<property name="Rate" type="d" access="readwrite"/>
<property name="Metadata" type="a{sv}" access="read"/>
<property name="Volume" type="d" access="readwrite"/>
<property name="Position" type="x" access="read"/>
<property name="MinimumRate" type="d" access="readwrite"/>
<property name="MaximumRate" type="d" access="readwrite"/>
<property name="CanGoNext" type="b" access="read"/>
<property name="CanGoPrevious" type="b" access="read"/>
<property name="CanPlay" type="b" access="read"/>
<property name="CanPause" type="b" access="read"/>
<property name="CanSeek" type="b" access="read"/>
<property name="CanControl" type="b" access="read"/>
<method name="Next"/>
<method name="Previous"/>
<method name="Pause"/>
<method name="PlayPause"/>
<method name="Stop"/>
<method name="Play"/>
<method name="Seek">
<arg type="x" direction="in"/>
</method>
<method name="SetPosition">
<arg type="o" direction="in"/>
<arg type="x" direction="in"/>
</method>
<method name="OpenUri">
<arg type="s" direction="in"/>
</method>
<!-- Clay additions -->
<method name="Mute"/>
<property name="Rating" type="x" access="readwrite"/>
<property name="Explicit" type="b" access="read"/>
</interface>
</node>

View file

@ -0,0 +1,18 @@
<node>
<interface name="org.mpris.MediaPlayer2.Playlists">
<property name="PlaylistCount" type="u" access="read"/>
<property name="Orderings" type="as" access="read"/>
<property name="ActivePlaylist" type="(b(oss))" access="read"/>
<method name="ActivatePlaylist">
<arg type="o" direction="in"/>
</method>
<method name="GetPlaylists">
<arg type="u" direction="in"/>
<arg type="u" direction="in"/>
<arg type="s" direction="in"/>
<arg type="b" direction="in"/>
</method>
</interface>
</node>

View file

@ -0,0 +1,24 @@
<node>
<interface name="org.mpris.MediaPlayer2.TrackList">
<property name="Tracks" type="oa" access="read"/>
<property name="CanEditTracks" type="b" access="read"/>
<method name="GetTracksMetadata">
<arg type="ao" direction="in"/>
</method>
<method name="AddTrack">
<arg type="s" direction="in"/>
<arg type="o" direction="in"/>
<arg type="b" direction="in"/>
</method>
<method name="RemoveTrack">
<arg type="o" direction="in"/>
</method>
<method name="GoTo">
<arg type="o" direction="in"/>
</method>
</interface>
</node>

View file

@ -0,0 +1,15 @@
<node>
<interface name="org.mpris.MediaPlayer2">
<property name="CanQuit" type="b" access="read"/>
<property name="Fullscreen" type="b" access="readwrite"/>
<property name="CanSetFullscreen" type="b" access="read"/>
<property name="CanRaise" type="b" access="read"/>
<property name="HasTrackList" type="b" access="read"/>
<property name="Identity" type="s" access="read"/>
<property name="DesktopEntry" type="s" access="read"/>
<property name="SupportedMimeTypes" type="as" access="read"/>
<property name="SupportedUriSchemes" type="as" access="read"/>
<method name="Quit"/>
<method name="Raise"/>
</interface>
</node>

278
clay/core/mpris2.py Normal file
View file

@ -0,0 +1,278 @@
"""
This module defines and starts a MPRIS2 dbus interface
"""
import sys
import pkg_resources
from pydbus import SessionBus, Variant
from clay.core import meta
from clay.playback.vlc import player
# pylint: disable=invalid-name,missing-docstring
class MPRIS2:
"""
An object that defines and implements the MPRIS2 protocol for Clay
"""
def __init__(self):
self._stopped = False
# MediaPlayer2 interface
def Raise(self):
pass
# TODO: Cleanup after ourselves
def Quit(self):
sys.exit(0)
@property
def CanQuit(self):
return True
@property
def Fullscreen(self):
pass
@Fullscreen.setter
def Fullscreen(self, _):
# We aren't graphical so we just ignore this call
pass
@property
def CanSetFullscreen(self):
return False
@property
def CanRaise(self):
return False
@property
def HasTrackList(self):
return True
@property
def Identity(self):
return "Clay Player"
@property
def DesktopEntry(self):
return "clay"
@property
def SupportedMimeTypes(self):
return []
@property
def SupportedUriSchemes(self):
return []
# MediaPlayer2 Player interface
def Next(self):
"""
Goes to the next song in the queue.
"""
player.next()
def Previous(self):
"""
Goes to previous in the queue
"""
player.prev()
def Pause(self):
"""
Pauses the backback
"""
if player.is_playing:
player.play_pause()
def PlayPause(self):
"""
Toggles playback, i.e. play if pause or pause if playing
"""
player.play_pause()
def Stop(self):
"""
Stops playback and returns to the beginning of the song.
"""
self._stopped = True
self.Pause()
player.seek(-1)
def Play(self):
"""
Starts or resumes playback.
"""
if self._stopped:
self._stopped = False
if not player.is_playing:
player.play_pause()
def Seek(self, offset):
"""
Seeks forward in the current track by the specified number of microseconds.
A negative value seeks backwards in the track until the value is current - offset
Or if that value would be lower to zero, zero.
Args:
offset: the number of microseconds to seek forwards.
"""
player.time = player.time + offset
def SetPosition(self, track_id, position):
"""
Sets the current position in microseconds.
"""
pass
# pylint: disable=no-else-return
@property
def PlaybackStatus(self):
"""
Returns the current status of clay.
"""
if self._stopped or player.queue.get_tracks() == []:
return "Stopped"
elif player.is_playing:
return "Playing"
else:
return "Paused"
@property
def LoopStatus(self):
"""
Returns:
Whether the song is not, single or playlist looping
"""
if player.get_is_repeat_one():
return "Track"
else:
return "None"
# TODO: We don't allow someone to control playback atm so this doesn't do anything
@property
def MinimumRate(self):
return -1.0
@property
def MaximumRate(self):
return 1.0
@property
def Rate(self):
"""
Returns the playback rate of the current song.
"""
return 0.0
@property
def Metadata(self):
try:
track = player.get_current_track()
except AttributeError:
return {}
return {
'mpris:trackid': Variant('o', '/org/clay/' + str(track.store_id)),
'mpris:artUrl': Variant('s', track.artist_art_url),
'xesam:title': Variant('s', track.title),
'xesam:artist': Variant('s', track.artist),
'xesam:album': Variant('s', track.album_name),
'xesam:url': Variant('s', track.cached_url),
}
@property
def CanPause(self):
return player.get_current_track() is not None
@property
def CanPlay(self):
return player.get_current_track() is not None
@property
def CanGoNext(self):
return len(player.queue.get_tracks()) > 1
@property
def CanGoPrevious(self):
#TODO fix
return len(player.queue.get_tracks()) > 1
@property
def CanSeek(self):
return player.get_current_track() is not None
@property
def CanControl(self):
return True
@property
def Shuffle(self):
return player.get_is_random()
@property
def Volume(self):
return player.volume / 100
@Volume.setter
def Volume(self, volume):
# Don't blast someone's ears off because they entered the wrong thing.
# Just enter it raw into volume since that is probably what they meant to do.
if volume > 1.0:
player.volume = int(volume)
else:
player.volume = int(volume * 100)
@property
def Position(self):
return player.play_progress
# The following are custom additions to the protocol for features that clay supports
def Mute(self):
"""
Mutes or unmutes the volume.
"""
player.mute()
@property
def Rating(self):
"""
Returns:
The rating of the current song.
"""
try:
return player.get_current_track().rating
except AttributeError:
return 0
@Rating.setter
def Rating(self, rating):
"""
Takes a rating and sets the current song to that rating.
1-2 thumbs down
4-5 thumbs up
0 None
"""
try:
player.get_current_track().rate_song(rating)
except AttributeError:
pass
@property
def Explicit(self):
track = player.get_current_track()
if track is None:
return False
else:
return track.explicit_rating != 0
bus = SessionBus()
MPRIS2.dbus = [pkg_resources.resource_string(__name__, "mpris/org.mpris.MediaPlayer2" +name+ ".xml")
.decode("utf-8")
for name in ("", ".Player", ".Playlists", ".TrackList")]
bus.publish("org.mpris.MediaPlayer2.clay", MPRIS2(),
('/org/mpris/MediaPlayer2', MPRIS2()))

View file

@ -9,7 +9,7 @@ from clay.core import meta
IS_INIT = False
try:
from dbus import SessionBus, Interface
from pydbus import SessionBus
IS_INIT = True
except ImportError:
ERROR_MESSAGE = 'Could not import dbus. OSD notifications will be disabled.'
@ -29,11 +29,7 @@ class _OSDManager(object):
if IS_INIT:
self.bus = SessionBus()
self.notifcations = self.bus.get_object(
"org.freedesktop.Notifications",
"/org/freedesktop/Notifications"
)
self.notify_interface = Interface(self.notifcations, "org.freedesktop.Notifications")
self.notifications = self.bus.get(".Notifications")
def notify(self, track):
"""
@ -46,7 +42,7 @@ class _OSDManager(object):
def _notify(self, track):
artist_art_filename = track.get_artist_art_filename()
self._last_id = self.notify_interface.Notify(
self._last_id = self.notifications.Notify(
meta.APP_NAME,
self._last_id,
artist_art_filename if artist_art_filename is not None else 'audio-headphones',

View file

@ -166,25 +166,14 @@ class AbstractPlayer:
playing=self._is_playing,
artist=track.artist,
title=track.title,
progress=self.get_play_progress_seconds(),
length=self.get_length_seconds(),
progress=self.play_progress_seconds,
length=self.length_seconds,
album_name=track.album_name,
album_url=track.album_url
)
with open('/tmp/clay.json', 'w') as statefile:
statefile.write(json.dumps(data, indent=4))
def enable_xorg_bindings(self):
"""Enable the global X bindings using keybinder"""
if os.environ.get("DISPLAY") is None:
logger.debug("X11 isn't running so we can't load the global keybinds")
return
from clay.ui.urwid.hotkeys import hotkey_manager
hotkey_manager.play_pause += self.play_pause
hotkey_manager.next += self.next
hotkey_manager.prev += lambda: self.seek_absolute(0)
def load_queue(self, data, current_index=None):
"""
Load queue & start playbac
@ -264,12 +253,6 @@ class AbstractPlayer:
"""
raise NotImplementedError
def get_play_progress(self):
"""
Return the current playback position in range ``[0;1]`` (``float``)
"""
raise NotImplementedError
def _download_track(self, url, error, track):
if error:
logger.error(
@ -297,19 +280,74 @@ class AbstractPlayer:
"""
raise NotImplementedError
def get_play_progress(self):
@property
def play_progress(self):
"""
Return current playback position in range ``[0;1]`` (``float``)
"""
raise NotImplementedError
def get_play_progress_seconds(self):
@property
def play_progress_seconds(self):
"""
Return the current playback position in seconds (``int``)
"""
raise NotImplementedError
def get_length_seconds(self):
@property
def time(self):
"""
Returns:
Get their current movie length in microseconds
e """
raise NotImplementedError
@time.setter
def time(self, time):
"""
Sets the current time in microseconds.
This is a pythonic alternative to seeking using absolute times instead of percentiles.
Args:
time: Time in microseconds.
"""
raise NotImplementedError
@property
def volume(self):
"""
Returns:
The current volume of in percentiles (0 = mute, 100 = 0dB)
"""
raise NotImplementedError
@volume.setter
def volume(self, volume):
"""
Args:
volume: the volume in percentiles (0 = mute, 1000 = 0dB)
Returns:
The current volume of in percentiles (0 = mute, 100 = 0dB)
"""
raise NotImplementedError
def mute(self):
"""
Mutes or unmutes the volume
"""
raise NotImplementedError
@property
def length(self):
"""
Returns:
The current playback position in microseconds
"""
raise NotImplementedError
@property
def length_seconds(self):
"""
Return currently played track's length in seconds (``int``).
"""

View file

@ -94,17 +94,9 @@ class VLCPlayer(AbstractPlayer):
assert event
self.broadcast_state()
self.media_position_changed.fire(
self.get_play_progress()
self.play_progress
)
# def create_station_from_track(self, track):
# """
# Request creation of new station from some track.
# Runs in background.
# """
# #self._create_station_notification = notification_area.notify('Creating station...')
# track.create_station_async(callback=self._create_station_from_track_ready)
def _create_station_ready(self, station, error):
"""
Called when a station is created.
@ -195,31 +187,89 @@ class VLCPlayer(AbstractPlayer):
self.media_player.pause()
else:
self.media_player.play()
def get_play_progress(self):
@property
def play_progress(self):
"""
Return current playback position in range ``[0;1]`` (``float``).
Returns:
A float of the current playback position in range of 0 to 1.
"""
return self.media_player.get_position()
def get_play_progress_seconds(self):
@property
def play_progress_seconds(self):
"""
Return current playback position in seconds (``int``).
"""
return int(self.media_player.get_position() * self.media_player.get_length() / 1000)
Returns:
The current playback position in seconds.
def get_length_seconds(self):
"""
Return currently played track's length in seconds (``int``).
return int(self.play_progress * self.media_player.get_length() / 1000)
@property
def time(self):
"""
return int(self.media_player.get_length() // 1000)
Returns:
Get their current movie length in microseconds
"""
return self.media_player.get_time()
@time.setter
def time(self, time):
"""
Sets the current time in microseconds.
This is a pythonic alternative to seeking using absolute times instead of percentiles.
Args:
time: Time in microseconds.
"""
self.media_player.set_time(time)
@property
def volume(self):
"""
Returns:
The current volume of in percentiles (0 = mute, 100 = 0dB)
"""
return self.media_player.audio_get_volume()
@volume.setter
def volume(self, volume):
"""
Args:
volume: the volume in percentiles (0 = mute, 1000 = 0dB)
Returns:
The current volume of in percentiles (0 = mute, 100 = 0dB)
"""
return self.media_player.audio_set_volume(volume)
def mute(self):
"""
Mutes or unmutes the volume
"""
self.media_player.set_mute(not self.media_player.audio_get_mute())
@property
def length(self):
"""
Returns:
The current playback position in microseconds
"""
return self.media_player.get_length()
@property
def length_seconds(self):
"""
Returns:
The current playback in position in seconds
"""
return self.length // 1000
def seek(self, delta):
"""
Seek to relative position.
*delta* must be a ``float`` in range ``[-1;1]``.
"""
self.media_player.set_position(self.get_play_progress() + delta)
self.media_player.set_position(self.play_progress + delta)
def seek_absolute(self, position):
"""
@ -276,4 +326,5 @@ class VLCPlayer(AbstractPlayer):
) == 0
self.media_player.set_equalizer(self.equalizer)
player = VLCPlayer() # pylint: disable=invalid-name

View file

@ -210,11 +210,6 @@ class AppWidget(urwid.Frame):
Handle keypress.
Can switch tabs, control playback, flags, notifications and app state.
"""
# for tab in self.tabs:
# if 'meta {}'.format(tab.page.key) == key:
# self.set_page(tab.page.__class__.__name__)
# return
hotkey_manager.keypress("global", self, super(AppWidget, self), size, key)
def show_debug(self):
@ -306,7 +301,6 @@ class AppWidget(urwid.Frame):
Quit app.
"""
self.loop = None
hotkey_manager.quit()
sys.exit(0)
def handle_escape(self):
@ -335,7 +329,7 @@ def main():
# Run the actual program
app_widget = AppWidget()
loop = urwid.MainLoop(app_widget, palette)
loop = urwid.MainLoop(app_widget, palette, event_loop=urwid.GLibEventLoop())
app_widget.set_loop(loop)
loop.screen.set_terminal_properties(256)
loop.run()

View file

@ -3,108 +3,21 @@ Hotkeys management.
Requires "gi" package and "Gtk" & "Keybinder" modules.
"""
# pylint: disable=broad-except
import os
import threading
from clay.core import EventHook, settings_manager, logger
from .notifications import notification_area
IS_INIT = False
from clay.core import settings_manager, logger
def report_error(exc):
"Print an error message to the debug screen"
logger.error("{0}: {1}".format(exc.__class__.__name__, exc))
try:
# pylint: disable=import-error
import gi
gi.require_version('Keybinder', '3.0') # noqa
gi.require_version('Gtk', '3.0') # noqa
from gi.repository import Keybinder, Gtk
# pylint: enable=import-error
except ImportError as error:
report_error(error)
ERROR_MESSAGE = "Couldn't import PyGObject"
except ValueError as error:
report_error(error)
ERROR_MESSAGE = "Couldn't find the Keybinder and/or Gtk modules"
except Exception as error:
report_error(error)
ERROR_MESSAGE = "There was unknown error: '{}'".format(error)
else:
IS_INIT = True
class _HotkeyManager(object):
"""
Manages configs.
Runs Gtk main loop in a thread.
"""
def __init__(self):
self._x_hotkeys = {}
self._hotkeys = self._parse_hotkeys()
self.config = None
self.play_pause = EventHook()
self.next = EventHook()
self.prev = EventHook()
if IS_INIT and os.environ.get("DISPLAY") is not None and \
settings_manager.get('x_keybinds', 'clay_settings'):
Keybinder.init()
self.initialize()
threading.Thread(target=Gtk.main).start()
elif not IS_INIT:
logger.debug("Not loading the global shortcuts.")
notification_area.notify(
ERROR_MESSAGE +
", this means the global shortcuts will not work.\n" +
"You can check the log for more details."
)
@staticmethod
def _to_gtk_modifier(key):
"""
Translates the modifies to the way that GTK likes them.
"""
key = key.strip()
if key == "meta":
key = "<alt>"
elif key in ("ctrl", "alt", "shift"):
key = "<" + key + ">"
else:
key = key
return key
def _parse_x_hotkeys(self):
"""
Reads out them configuration file and parses them into hotkeys readable by GTK.
"""
hotkey_default_config = settings_manager.get_default_config_section('hotkeys', 'x_hotkeys')
mod_key = settings_manager.get('mod_key', 'hotkeys')
hotkeys = {}
for action in hotkey_default_config:
key_seq = settings_manager.get(action, 'hotkeys', 'x_hotkeys')
for key in key_seq.split(', '):
hotkey = key.split(' + ')
if hotkey[0].strip() == 'mod':
hotkey[0] = mod_key
hotkey = [self._to_gtk_modifier(key) for key in hotkey]
hotkeys[action] = ''.join(hotkey)
return hotkeys
def _parse_hotkeys(self):
"""
Reads out the configuration file and parse them into a hotkeys for urwid.
@ -128,10 +41,6 @@ class _HotkeyManager(object):
return hotkeys
def quit(self):
"""Quits the keybinder"""
Gtk.main_quit()
def keypress(self, name, caller, super_, size, key):
"""
Process the pressed key by looking it up in the configuration file
@ -148,25 +57,4 @@ class _HotkeyManager(object):
return ret
def initialize(self):
"""
Unbind previous hotkeys, re-read config & bind new hotkeys.
"""
for operation, key in self._x_hotkeys.items():
Keybinder.unbind(key)
self._x_hotkeys = self._parse_x_hotkeys()
for operation, key in self._x_hotkeys.items():
Keybinder.bind(key, self.fire_hook, operation)
def fire_hook(self, key, operation):
"""
Fire hook by name.
"""
assert key
getattr(self, operation).fire()
hotkey_manager = _HotkeyManager() # pylint: disable=invalid-name

View file

@ -131,8 +131,8 @@ class PlayBar(urwid.Pile):
meta.APP_NAME,
meta.VERSION_WITH_CODENAME
)
progress = player.get_play_progress_seconds()
total = player.get_length_seconds()
progress = player.play_progress_seconds
total = player.length_seconds
return (self.get_style(), u' {} {} - {} {} [{:02d}:{:02d} / {:02d}:{:02d}]'.format(
# u'|>' if player.is_playing else u'||',
# self.get_rotating_bar(),
@ -155,7 +155,7 @@ class PlayBar(urwid.Pile):
e.g. current track or playback flags.
"""
self.text.set_text(self.get_text())
self.progressbar.set_progress(player.get_play_progress())
self.progressbar.set_progress(player.play_progress)
self.progressbar.set_done_style(
'progressbar_done'
if player.is_playing

View file

@ -2,3 +2,4 @@ gmusicapi==10.1.2
PyYAML==3.13
urwid==2.0.0
codename==1.1
pydbus==0.6.0