Merge branch 'porcelain' of https://github.com/and3rson/clay into porcelain

This commit is contained in:
Valentijn 2018-09-18 17:44:32 +02:00
commit 1434ae0fec
16 changed files with 343 additions and 21 deletions

View file

@ -27,7 +27,7 @@
Standalone command line player for Google Play Music. Standalone command line player for Google Play Music.
This app wouldn't be possible without the wonderful [gmusicapi] and [VLC] libraries. This app wouldn't be possible without the wonderful [gmusicapi] and [VLC] & MPV libraries.
This project is neither affiliated nor endorsed by Google. This project is neither affiliated nor endorsed by Google.
@ -65,7 +65,7 @@ Documentation is [available here](http://clay.readthedocs.io/en/latest/).
- [gmusicapi] (PyPI) - [gmusicapi] (PyPI)
- [urwid] (PyPI) - [urwid] (PyPI)
- [PyYAML] (PyPI) - [PyYAML] (PyPI)
- lib[VLC] (native, distributed with VLC player) - lib[VLC] (native, distributed with VLC player) OR libMPV (native, distributed with MPV)
- [setproctitle] (optional) PyPI, used to change clay process name from 'python' to 'clay') - [setproctitle] (optional) PyPI, used to change clay process name from 'python' to 'clay')
- [pydbus] (PyPI) - [pydbus] (PyPI)
@ -100,8 +100,7 @@ Documentation is [available here](http://clay.readthedocs.io/en/latest/).
# Installation # Installation
1. Install Python 3, pydbus, PyGObject, and VLC from your package manager. 1. Install Python 3, pydbus, PyGObject, and VLC or MPV from your package manager.
## Method 1 (PyPi, automatic) ## Method 1 (PyPi, automatic)
@ -174,6 +173,14 @@ bind the keys to your windowing system of choice.
- You will also need to know your Device ID. Thanks to [gmusicapi], the app should display possible IDs once you enter a wrong one. - You will also need to know your Device ID. Thanks to [gmusicapi], the app should display possible IDs once you enter a wrong one.
- Please be aware that this app has not been tested with 2FA yet. - Please be aware that this app has not been tested with 2FA yet.
- For people with 2FA, you can just create an app password in Google accounts page and proceed normally. (Thanks @j605) - For people with 2FA, you can just create an app password in Google accounts page and proceed normally. (Thanks @j605)
- By default VLC is used. If you want to use MPV instead, add the following line to your Clay config file (`~/.config/clay/config.yaml`) in `clay_settings` section:
```yaml
# ...
clay_settings:
player_class: clay.playback.mpv:MPVPlayer
# ...
```
# Controls # Controls

View file

@ -9,9 +9,13 @@ sys.path.insert(0, '.') # noqa
import argparse import argparse
from clay.core import meta, settings_manager from clay.core import meta, settings_manager
from clay.playback.vlc import player from clay.playback.player import get_player
import clay.ui.urwid as urwid import clay.ui.urwid as urwid
player = get_player() # pylint: disable=invalid-name
class MultilineVersionAction(argparse.Action): class MultilineVersionAction(argparse.Action):
""" """
An argparser action for multiple lines so we can display the copyright notice An argparser action for multiple lines so we can display the copyright notice

View file

@ -63,6 +63,7 @@ hotkeys:
clay_settings: clay_settings:
unicode: true unicode: true
player_class: clay.playback.vlc:VLCPlayer
play_settings: play_settings:
authtoken: authtoken:

View file

@ -192,6 +192,7 @@ class Track(object):
image = Image.open(BytesIO(data)) image = Image.open(BytesIO(data))
image.thumbnail((128, 128)) image.thumbnail((128, 128))
out = BytesIO() out = BytesIO()
image = image.convert('RGB')
image.save(out, format='JPEG') image.save(out, format='JPEG')
data = out.getvalue() data = out.getvalue()
settings_manager.save_file_to_cache(self.artist_art_filename, data) settings_manager.save_file_to_cache(self.artist_art_filename, data)

View file

@ -7,7 +7,10 @@ import pkg_resources
from pydbus import SessionBus, Variant from pydbus import SessionBus, Variant
from pydbus.generic import signal from pydbus.generic import signal
from clay.core import meta from clay.core import meta
from clay.playback.vlc import player from clay.playback.player import get_player
player = get_player() # pylint: disable=invalid-name
# pylint: disable=invalid-name,missing-docstring # pylint: disable=invalid-name,missing-docstring
@ -175,13 +178,16 @@ class MPRIS2:
try: try:
track = player.get_current_track() track = player.get_current_track()
except AttributeError: except AttributeError:
track = None
if track is None:
return {} return {}
return { return {
'mpris:trackid': Variant('o', '/org/clay/' + str(track.store_id)), 'mpris:trackid': Variant('o', '/org/clay/' + str(track.store_id)),
'mpris:artUrl': Variant('s', track.artist_art_url), 'mpris:artUrl': Variant('s', track.artist_art_url),
'xesam:title': Variant('s', track.title), 'xesam:title': Variant('s', track.title),
'xesam:artist': Variant('s', track.artist), 'xesam:artist': Variant('s', track.artist.name),
'xesam:album': Variant('s', track.album_name), 'xesam:album': Variant('s', track.album_name),
'xesam:url': Variant('s', track.cached_url), 'xesam:url': Variant('s', track.cached_url),
} }
@ -230,7 +236,7 @@ class MPRIS2:
@property @property
def Position(self): def Position(self):
return player.play_progress return player.time
# The following are custom additions to the protocol for features that clay supports # The following are custom additions to the protocol for features that clay supports
def Mute(self): def Mute(self):

View file

View file

@ -1,4 +1,4 @@
""" <"""
An abstract class for playback An abstract class for playback
Copyright (c) 2018, Valentijn van de Beek Copyright (c) 2018, Valentijn van de Beek
@ -144,8 +144,6 @@ class AbstractPlayer:
def __init__(self): def __init__(self):
self._create_station_notification = None self._create_station_notification = None
self._loading = False
self._playing = False
self.queue = _Queue() self.queue = _Queue()
# Add notification actions that we are going to use. # Add notification actions that we are going to use.
@ -170,8 +168,8 @@ class AbstractPlayer:
) )
else: else:
data = dict( data = dict(
loading=self._loading, loading=self.loading,
playing=self._playing, playing=self.playing,
artist=track.artist, artist=track.artist,
title=track.title, title=track.title,
progress=self.play_progress_seconds, progress=self.play_progress_seconds,

270
clay/playback/mpv.py Normal file
View file

@ -0,0 +1,270 @@
"""
An implementation of the Clay player using VLC
Copyright (c) 2018, Clay Contributors
"""
from ctypes import CFUNCTYPE, c_void_p, c_int, c_char_p
from clay.core import osd_manager, logger, meta, settings_manager
import mpv
from .abstract import AbstractPlayer
class MPVPlayer(AbstractPlayer):
"""
Interface to MPV. Uses Queue as a playback plan.
Emits various events if playback state, tracks or play flags change.
Singleton.
"""
def __init__(self):
self.media_player = mpv.MPV()
self.media_player.observe_property('pause', self._media_state_changed)
self.media_player.observe_property('stream-open-filename', self._media_state_changed)
self.media_player.observe_property('stream-pos', self._media_position_changed)
self.media_player.observe_property('idle-active', self._media_end_reached)
AbstractPlayer.__init__(self)
def _media_state_changed(self, *_):
"""
Called when a libVLC playback state changes.
Broadcasts playback state & fires :attr:`media_state_changed` event.
"""
self.broadcast_state()
self.media_state_changed.fire(self.loading, self.playing)
def _media_end_reached(self, event, value):
"""
Called when end of currently played track is reached.
Advances to the next track.
"""
if value:
self.next()
def _media_position_changed(self, *_):
"""
Called when playback position changes (this happens few times each second.)
Fires :attr:`.media_position_changed` event.
"""
self.broadcast_state()
self.media_position_changed.fire(
self.play_progress
)
def _create_station_ready(self, station, error):
"""
Called when a station is created.
If *error* is ``None``, load new station's tracks into queue.
"""
if error:
self._create_station_notification.update(
'Failed to create station: {}'.format(str(error))
)
return
if not station.get_tracks():
self._create_station_notification.update(
'Newly created station is empty :('
)
return
self.load_queue(station.get_tracks())
self._create_station_notification.update('Station ready!')
def play(self):
"""
Pick current track from a queue and requests media stream URL.
Completes in background.
"""
track = self.queue.get_current_track()
if track is None:
return
self._loading = True
self.broadcast_state()
self.track_changed.fire(track)
if settings_manager.get('download_tracks', 'play_settings') or \
settings_manager.get_is_file_cached(track.filename):
path = settings_manager.get_cached_file_path(track.filename)
if path is None:
logger.debug('Track %s not in cache, downloading...', track.store_id)
track.get_url(callback=self._download_track)
else:
logger.debug('Track %s in cache, playing', track.store_id)
self._play_ready(path, None, track)
else:
logger.debug('Starting to stream %s', track.store_id)
track.get_url(callback=self._play_ready)
def _play_ready(self, url, error, track):
"""
Called once track's media stream URL request completes.
If *error* is ``None``, tell libVLC to play media by *url*.
"""
self._loading = False
if error:
#notification_area.notify('Failed to request media URL: {}'.format(str(error)))
logger.error(
'Failed to request media URL for track %s: %s',
track.original_data,
str(error)
)
return
assert track
self.media_player.play(url)
osd_manager.notify(track.title, "by {}\nfrom {}\n".format(track.artist, track.album_name),
("media-skip-backward", "media-playback-pause", "media-skip-forward"),
track.get_artist_art_filename())
@property
def playing(self):
"""
True if a song is being played at the moment.
"""
return not self.media_player.pause
def play_pause(self):
"""
Toggle playback, i.e. play if paused or pause if playing.
"""
self.media_player.pause = not self.media_player.pause
@property
def play_progress(self):
"""
Return current playback position in range ``[0;1]`` (``float``).
"""
try:
return self.media_player.playback_time / self.media_player.duration
except TypeError:
return 0
@property
def play_progress_seconds(self):
"""
Return current playback position in seconds (``int``).
"""
progress = self.media_player.playback_time
if progress is None:
return 0
return int(progress)
@property
def length(self):
"""
Return currently played track's length in microseconds (``int``).
"""
return self.length_seconds * 1e6
@property
def length_seconds(self):
"""
Return currently played track's length in seconds (``int``).
"""
duration = self.media_player.duration
if duration is None:
duration = 0
return int(duration)
@property
def time(self):
"""
Returns:
Get their current movie length in microseconds
"""
try:
return int(self.media_player.playback_time * 1e6)
except TypeError:
return 0
@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.
"""
try:
self.media_player.playback_time = int(time / 1e6)
except TypeError:
pass
else:
self._seeked()
def seek(self, delta):
"""
Seek to relative position.
*delta* must be a ``float`` in range ``[-1;1]``.
"""
try:
self.media_player.seek(int(self.length_seconds * delta))
except:
pass
def seek_absolute(self, position):
"""
Seek to absolute position.
*position* must be a ``float`` in range ``[0;1]``.
"""
try:
self.media_player.seek(int(self.length_seconds * position), reference='absolute')
except:
pass
@staticmethod
def get_equalizer_freqs():
"""
Return a list of equalizer frequencies for each band.
"""
return [0] * 8
def get_equalizer_amps(self):
"""
Return a list of equalizer amplifications for each band.
"""
return [0] * 8
def set_equalizer_value(self, index, amp):
"""
Set equalizer amplification for specific band.
"""
def set_equalizer_values(self, amps):
"""
Set a list of equalizer amplifications for each band.
"""
@property
def volume(self):
"""
Returns:
The current volume of in percentiles (0 = mute, 100 = 0dB)
"""
return self.media_player.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)
"""
self.media_player.volume = volume
def mute(self):
"""
Mutes or unmutes the volume
"""
self.media_player.mute = not self.media_player.mute

20
clay/playback/player.py Normal file
View file

@ -0,0 +1,20 @@
import importlib
from clay.core.settings import settings_manager
_PLAYER = None
def get_player():
global _PLAYER
if _PLAYER is None:
player_import_str = settings_manager.get('player_class', 'clay_settings')
if player_import_str is None:
player_import_str = 'clay.playback.vlc:VLCPlayer'
player_module, _, player_var = player_import_str.rpartition(':')
module = importlib.import_module(player_module)
player_class = getattr(module, player_var)
_PLAYER = player_class()
return _PLAYER

View file

@ -328,6 +328,3 @@ class VLCPlayer(AbstractPlayer):
index index
) == 0 ) == 0
self.media_player.set_equalizer(self.equalizer) self.media_player.set_equalizer(self.equalizer)
player = VLCPlayer() # pylint: disable=invalid-name

0
clay/ui/__init__.py Normal file
View file

View file

@ -3,7 +3,7 @@ import sys
import threading import threading
from clay.core import gp, settings_manager from clay.core import gp, settings_manager
from clay.playback.vlc import player from clay.playback.player import get_player
from .clipboard import copy from .clipboard import copy
from .hotkeys import hotkey_manager from .hotkeys import hotkey_manager
@ -13,6 +13,9 @@ from .songlist import SongListBox
from .pages import * from .pages import *
player = get_player() # pylint: disable=invalid-name
class AppWidget(urwid.Frame): class AppWidget(urwid.Frame):
""" """
Root widget. Root widget.

View file

@ -4,9 +4,13 @@ Components for "Queue" page.
import urwid import urwid
from .page import AbstractPage from .page import AbstractPage
from clay.playback.vlc import player from clay.playback.player import get_player
from clay.ui.urwid import SongListBox from clay.ui.urwid import SongListBox
player = get_player() # pylint: disable=invalid-name
class QueuePage(urwid.Columns, AbstractPage): class QueuePage(urwid.Columns, AbstractPage):
""" """
Queue page. Queue page.

View file

@ -5,9 +5,13 @@ import urwid
from .page import AbstractPage from .page import AbstractPage
from clay.core import settings_manager from clay.core import settings_manager
from clay.playback.vlc import player from clay.playback.player import get_player
from clay.ui.urwid import hotkey_manager from clay.ui.urwid import hotkey_manager
player = get_player() # pylint: disable=invalid-name
class Slider(urwid.Widget): class Slider(urwid.Widget):
""" """
Represents a (TODO: vertical) slider for equalizer band modification. Represents a (TODO: vertical) slider for equalizer band modification.

View file

@ -5,7 +5,11 @@ PlayBar widget.
import urwid import urwid
from clay.core import settings_manager, meta from clay.core import settings_manager, meta
from clay.playback.vlc import player from clay.playback.player import get_player
player = get_player() # pylint: disable=invalid-name
class ProgressBar(urwid.Widget): class ProgressBar(urwid.Widget):
""" """

View file

@ -16,13 +16,16 @@ except ImportError:
import urwid import urwid
from clay.core import gp, settings_manager from clay.core import gp, settings_manager
from clay.playback.vlc import player from clay.playback.player import get_player
from .notifications import notification_area from .notifications import notification_area
from .hotkeys import hotkey_manager from .hotkeys import hotkey_manager
from .clipboard import copy from .clipboard import copy
player = get_player() # pylint: disable=invalid-name
class SongListItem(urwid.Pile): class SongListItem(urwid.Pile):
""" """
Widget that represents single song item. Widget that represents single song item.