From 91681acb5ba636c17f74a335a3fafddb6a71a177 Mon Sep 17 00:00:00 2001 From: Valentijn Date: Thu, 16 Aug 2018 21:07:06 +0200 Subject: [PATCH] An implementation of MPRIS2 to replace keybinder and change some getter/setter to properties --- clay/app.py | 18 -- clay/core/__init__.py | 1 + clay/core/config.yaml | 6 - clay/core/gp.py | 2 +- .../mpris/org.mpris.MediaPlayer2.Player.xml | 42 +++ .../org.mpris.MediaPlayer2.Playlists.xml | 18 ++ .../org.mpris.MediaPlayer2.TrackList.xml | 24 ++ clay/core/mpris/org.mpris.MediaPlayer2.xml | 15 + clay/core/mpris2.py | 278 ++++++++++++++++++ clay/core/osd.py | 10 +- clay/playback/abstract.py | 82 ++++-- clay/playback/vlc.py | 91 ++++-- clay/ui/urwid/__init__.py | 8 +- clay/ui/urwid/hotkeys.py | 114 +------ clay/ui/urwid/playbar.py | 6 +- requirements.txt | 1 + 16 files changed, 519 insertions(+), 197 deletions(-) create mode 100644 clay/core/mpris/org.mpris.MediaPlayer2.Player.xml create mode 100644 clay/core/mpris/org.mpris.MediaPlayer2.Playlists.xml create mode 100644 clay/core/mpris/org.mpris.MediaPlayer2.TrackList.xml create mode 100644 clay/core/mpris/org.mpris.MediaPlayer2.xml create mode 100644 clay/core/mpris2.py diff --git a/clay/app.py b/clay/app.py index 4064703..f8f9f3f 100755 --- a/clay/app.py +++ b/clay/app.py @@ -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() diff --git a/clay/core/__init__.py b/clay/core/__init__.py index 4892e34..46b7f1e 100644 --- a/clay/core/__init__.py +++ b/clay/core/__init__.py @@ -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 diff --git a/clay/core/config.yaml b/clay/core/config.yaml index ea46d2e..8250a7a 100644 --- a/clay/core/config.yaml +++ b/clay/core/config.yaml @@ -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: diff --git a/clay/core/gp.py b/clay/core/gp.py index b281602..144c372 100644 --- a/clay/core/gp.py +++ b/clay/core/gp.py @@ -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) diff --git a/clay/core/mpris/org.mpris.MediaPlayer2.Player.xml b/clay/core/mpris/org.mpris.MediaPlayer2.Player.xml new file mode 100644 index 0000000..9367fb8 --- /dev/null +++ b/clay/core/mpris/org.mpris.MediaPlayer2.Player.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/clay/core/mpris/org.mpris.MediaPlayer2.Playlists.xml b/clay/core/mpris/org.mpris.MediaPlayer2.Playlists.xml new file mode 100644 index 0000000..4064777 --- /dev/null +++ b/clay/core/mpris/org.mpris.MediaPlayer2.Playlists.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/clay/core/mpris/org.mpris.MediaPlayer2.TrackList.xml b/clay/core/mpris/org.mpris.MediaPlayer2.TrackList.xml new file mode 100644 index 0000000..e63a5ea --- /dev/null +++ b/clay/core/mpris/org.mpris.MediaPlayer2.TrackList.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/clay/core/mpris/org.mpris.MediaPlayer2.xml b/clay/core/mpris/org.mpris.MediaPlayer2.xml new file mode 100644 index 0000000..8c76c24 --- /dev/null +++ b/clay/core/mpris/org.mpris.MediaPlayer2.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/clay/core/mpris2.py b/clay/core/mpris2.py new file mode 100644 index 0000000..e07788e --- /dev/null +++ b/clay/core/mpris2.py @@ -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())) diff --git a/clay/core/osd.py b/clay/core/osd.py index c1c8259..7de7d3a 100644 --- a/clay/core/osd.py +++ b/clay/core/osd.py @@ -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', diff --git a/clay/playback/abstract.py b/clay/playback/abstract.py index c4d2b02..6ec868a 100644 --- a/clay/playback/abstract.py +++ b/clay/playback/abstract.py @@ -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``). """ diff --git a/clay/playback/vlc.py b/clay/playback/vlc.py index 82e7a36..d8733b6 100644 --- a/clay/playback/vlc.py +++ b/clay/playback/vlc.py @@ -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 diff --git a/clay/ui/urwid/__init__.py b/clay/ui/urwid/__init__.py index a2c69c5..697ed61 100644 --- a/clay/ui/urwid/__init__.py +++ b/clay/ui/urwid/__init__.py @@ -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() diff --git a/clay/ui/urwid/hotkeys.py b/clay/ui/urwid/hotkeys.py index 29a2833..f8c74fd 100644 --- a/clay/ui/urwid/hotkeys.py +++ b/clay/ui/urwid/hotkeys.py @@ -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 = "" - 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 diff --git a/clay/ui/urwid/playbar.py b/clay/ui/urwid/playbar.py index 9c430f7..9ccad00 100644 --- a/clay/ui/urwid/playbar.py +++ b/clay/ui/urwid/playbar.py @@ -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 diff --git a/requirements.txt b/requirements.txt index 4190993..d8675cd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ gmusicapi==10.1.2 PyYAML==3.13 urwid==2.0.0 codename==1.1 +pydbus==0.6.0