mirror of
https://github.com/vale981/clay
synced 2025-03-04 09:11:37 -05:00
Initial
This commit is contained in:
parent
4b4e90be12
commit
38f4691a50
17 changed files with 8228 additions and 0 deletions
6
Makefile
Normal file
6
Makefile
Normal file
|
@ -0,0 +1,6 @@
|
|||
build:
|
||||
docker build -t clay .
|
||||
|
||||
run: | build
|
||||
docker run -it clay
|
||||
|
93
README.md
Normal file
93
README.md
Normal file
|
@ -0,0 +1,93 @@
|
|||
# Clay [alpha]
|
||||
|
||||
Standalone command line player for Google Play Music.
|
||||
|
||||
This app wouldn't be possible without the wonderful [gmusicapi] and [VLC] libraries.
|
||||
|
||||
This project is neither affiliated nor endorsed by Google.
|
||||
|
||||
It's being actively developed, but is still in the early alpha version, so many features are missing and/or may be bugged.
|
||||
|
||||

|
||||
|
||||
# Requirements
|
||||
|
||||
- Python 3.x (native)
|
||||
- [gmusicapi] (PYPI)
|
||||
- [urwid] (PYPI)
|
||||
- [pyyaml] (PYPI)
|
||||
- lib[vlc] (native, distributed with VLC player)
|
||||
|
||||
# What works
|
||||
- Playback
|
||||
- Music library
|
||||
- Playlists
|
||||
- Queue
|
||||
- Configuration
|
||||
- Caching (partially)
|
||||
- Basic error handling
|
||||
|
||||
# What is developer
|
||||
|
||||
- Search
|
||||
- Add to playlist
|
||||
- Like/dislike
|
||||
- Caching
|
||||
- Other functionality that is supported by [gmusicapi]
|
||||
|
||||
# Installation
|
||||
|
||||
0. Install Python 3 and VLC.
|
||||
|
||||
## Method 1 (automatic)
|
||||
|
||||
1. Source the 'activate.sh' script. It will initialize the Python virtual env and install the dependencies:
|
||||
|
||||
$ source activate.sh
|
||||
|
||||
2. Run the player:
|
||||
|
||||
$ ./app.py
|
||||
|
||||
## Method 2 (manual)
|
||||
|
||||
1. Create & activate virtualenv:
|
||||
|
||||
$ virtualenv .env
|
||||
$ source .env/bin/activate
|
||||
|
||||
2. Install the requirements:
|
||||
|
||||
$ pip install -r requirements.txt
|
||||
|
||||
3. Run the player:
|
||||
|
||||
$ ./app.py
|
||||
|
||||
# Configuration
|
||||
|
||||
In order to use this app, you need to know your Device ID. Typically gmusicapi should display possible IDs once you type a wrong one.
|
||||
Also be aware that this app has not been tested with 2FA yet.
|
||||
|
||||
# Controls
|
||||
|
||||
- `<UP|DOWN|LEFT|RIGHT>` - nagivate around
|
||||
- `<ALT> + 1..9` - switch active tab
|
||||
- `<ENTER>` - play selected track
|
||||
- `<CTRL> w` - play/pause
|
||||
- `<CTRL> e` - play next song
|
||||
- `<SHIFT> <LEFT|RIGHT>` - seek backward/forward by 5% of the song duration
|
||||
- `<CTRL> s` - toggle shuffle
|
||||
- `<CTRL> r` - toggle song repeat
|
||||
|
||||
# Credits
|
||||
|
||||
Made by Andrew Dunai.
|
||||
|
||||
Regards to [gmusicapi] and [VLC] who made this possible.
|
||||
|
||||
[gmusicapi]: https://github.com/simon-weber/gmusicapi
|
||||
[VLC]: https://wiki.videolan.org/python_bindings
|
||||
[urwid]: urwid.org/
|
||||
[pyyaml]: https://github.com/yaml/pyyaml
|
||||
|
10
activate.sh
Normal file
10
activate.sh
Normal file
|
@ -0,0 +1,10 @@
|
|||
#!/bin/bash
|
||||
|
||||
if ! [[ -d ".env" ]]
|
||||
then
|
||||
virtualenv .env
|
||||
fi
|
||||
|
||||
. .env/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
214
app.py
Executable file
214
app.py
Executable file
|
@ -0,0 +1,214 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
from collections import OrderedDict
|
||||
|
||||
import urwid
|
||||
|
||||
from player import player
|
||||
from pages import StartUp, Error
|
||||
from mylibrary import MyLibrary
|
||||
from myplaylists import MyPlaylists
|
||||
from playerqueue import Queue
|
||||
from settings import Settings
|
||||
|
||||
|
||||
loop = None
|
||||
|
||||
PALETTE = [
|
||||
('logo', '', '', '', '#F54', ''),
|
||||
|
||||
('bg', '', '', '', '#FFF', '#222'),
|
||||
('primary', '', '', '', '#F54', '#FFF'),
|
||||
('secondary', '', '', '', '#17F', '#FFF'),
|
||||
('selected', '', '', '', '#FFF', '#444'),
|
||||
('primary_inv', '', '', '', '#FFF', '#17F'),
|
||||
('secondary_inv', '', '', '', '#FFF', '#F17'),
|
||||
('progress', '', '', '', '#FFF', '#F54'),
|
||||
('progress_remaining', '', '', '', '#FFF', '#444'),
|
||||
|
||||
('panel', '', '', '', '#FFF', '#222'),
|
||||
('panel_focus', '', '', '', '#FFF', '#F54'),
|
||||
|
||||
('line1', '', '', '', '#FFF', ''),
|
||||
('line1_focus', '', '', '', '#FFF', '#444'),
|
||||
('line1_active', '', '', '', '#F54', ''),
|
||||
('line1_active_focus', '', '', '', '#F54', '#444'),
|
||||
('line2', '', '', '', '#AAA', ''),
|
||||
('line2_focus', '', '', '', '#AAA', '#444'),
|
||||
|
||||
('input', '', '', '', '#FFF', '#444'),
|
||||
('input_focus', '', '', '', '#FFF', '#F54'),
|
||||
]
|
||||
|
||||
|
||||
class PlayProgress(urwid.ProgressBar):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.track = None
|
||||
|
||||
def get_text(self):
|
||||
if self.track is None:
|
||||
return 'Idle'
|
||||
progress = player.get_play_progress_seconds()
|
||||
total = player.get_length_seconds()
|
||||
return ' {} - {} [{:02d}:{:02d} / {:02d}:{:02d}] {} {}'.format(
|
||||
self.track.artist,
|
||||
self.track.title,
|
||||
progress // 60,
|
||||
progress % 60,
|
||||
total // 60,
|
||||
total % 60,
|
||||
'' if player.get_is_random() else ' ',
|
||||
'' if player.get_is_repeat_one() else ' ',
|
||||
)
|
||||
|
||||
def set_track(self, track):
|
||||
self.track = track
|
||||
|
||||
def update(self):
|
||||
self._invalidate()
|
||||
|
||||
|
||||
class AppWidget(urwid.Frame):
|
||||
class Tab(urwid.Text):
|
||||
def __init__(self, page_class):
|
||||
self.page_class = page_class
|
||||
# self.attrwrap = urwid.AttrWrap(urwid.Text(), 'panel')
|
||||
super().__init__(
|
||||
self.get_title()
|
||||
)
|
||||
|
||||
def set_active(self, active):
|
||||
self.set_text(
|
||||
[('panel_focus' if active else 'panel', self.get_title())]
|
||||
)
|
||||
|
||||
def get_title(self):
|
||||
return ' {} {} '.format(
|
||||
self.page_class.key,
|
||||
self.page_class.name
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.pages = [
|
||||
StartUp,
|
||||
Error,
|
||||
MyLibrary,
|
||||
MyPlaylists,
|
||||
Queue,
|
||||
Settings
|
||||
]
|
||||
self.tabs = [
|
||||
AppWidget.Tab(page_class)
|
||||
for page_class
|
||||
in [
|
||||
MyLibrary,
|
||||
MyPlaylists,
|
||||
Queue,
|
||||
Settings
|
||||
]
|
||||
]
|
||||
print(self.tabs)
|
||||
self.header = urwid.Pile([
|
||||
# urwid.Divider('\u2500'),
|
||||
urwid.AttrWrap(urwid.Columns([
|
||||
('pack', tab)
|
||||
for tab
|
||||
in self.tabs
|
||||
], dividechars=1), 'panel'),
|
||||
# urwid.Divider('\u2500')
|
||||
])
|
||||
self.seekbar = PlayProgress(
|
||||
'progress_remaining',
|
||||
'progress',
|
||||
current=0,
|
||||
done=100,
|
||||
satt='bg'
|
||||
)
|
||||
self.panel = urwid.Pile([
|
||||
urwid.Divider('\u2500'),
|
||||
self.seekbar
|
||||
])
|
||||
self.current_page = StartUp(self)
|
||||
super().__init__(
|
||||
header=self.header,
|
||||
footer=self.panel,
|
||||
body=self.current_page
|
||||
)
|
||||
|
||||
player.media_position_changed += self.media_position_changed
|
||||
player.track_changed += self.track_changed
|
||||
player.playback_flags_changed += self.playback_flags_changed
|
||||
|
||||
def media_position_changed(self, progress):
|
||||
if progress < 0:
|
||||
progress = 0
|
||||
self.seekbar.set_completion(progress * 100)
|
||||
loop.draw_screen()
|
||||
# sleep(0.2)
|
||||
|
||||
# self.set_page(MyLibrary())
|
||||
|
||||
def track_changed(self, track):
|
||||
self.seekbar.set_track(track)
|
||||
|
||||
def playback_flags_changed(self):
|
||||
self.seekbar.update()
|
||||
|
||||
def set_page(self, page_class, *args):
|
||||
if isinstance(page_class, str):
|
||||
page_class = [
|
||||
page
|
||||
for page
|
||||
in self.pages
|
||||
if page.__name__ == page_class
|
||||
][0]
|
||||
self.current_page = page_class(self, *args)
|
||||
self.contents['body'] = (self.current_page, None)
|
||||
|
||||
for tab in self.tabs:
|
||||
tab.set_active(False)
|
||||
if tab.page_class == page_class:
|
||||
tab.set_active(True)
|
||||
|
||||
self.redraw()
|
||||
|
||||
def redraw(self):
|
||||
if loop:
|
||||
loop.draw_screen()
|
||||
|
||||
def keypress(self, size, key):
|
||||
if isinstance(self.current_page, StartUp):
|
||||
return
|
||||
for tab in self.tabs:
|
||||
if 'meta {}'.format(tab.page_class.key) == key:
|
||||
self.set_page(tab.page_class)
|
||||
return
|
||||
|
||||
if key == 'ctrl w':
|
||||
player.play_pause()
|
||||
elif key == 'ctrl e':
|
||||
player.next(True)
|
||||
elif key == 'shift right':
|
||||
player.seek(0.05)
|
||||
elif key == 'shift left':
|
||||
player.seek(-0.05)
|
||||
elif key == 'ctrl s':
|
||||
player.set_random(not player.get_is_random())
|
||||
elif key == 'ctrl r':
|
||||
player.set_repeat_one(not player.get_is_repeat_one())
|
||||
else:
|
||||
super().keypress(size, key)
|
||||
|
||||
|
||||
def main():
|
||||
global loop
|
||||
app_widget = AppWidget()
|
||||
loop = urwid.MainLoop(app_widget, PALETTE)
|
||||
loop.screen.set_terminal_properties(256)
|
||||
loop.run()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
16
eventhook.py
Normal file
16
eventhook.py
Normal file
|
@ -0,0 +1,16 @@
|
|||
class EventHook(object):
|
||||
def __init__(self):
|
||||
self.event_handlers = []
|
||||
|
||||
def __iadd__(self, handler):
|
||||
self.event_handlers.append(handler)
|
||||
return self
|
||||
|
||||
def __isub__(self, handler):
|
||||
self.event_handlers.remove(handler)
|
||||
return self
|
||||
|
||||
def fire(self, *args, **kwargs):
|
||||
for handler in self.event_handlers:
|
||||
handler(*args, **kwargs)
|
||||
|
83
gp.py
Normal file
83
gp.py
Normal file
|
@ -0,0 +1,83 @@
|
|||
from gmusicapi.clients import Mobileclient
|
||||
from threading import Thread, Lock
|
||||
|
||||
|
||||
def async(fn):
|
||||
def wrapper(*args, **kwargs):
|
||||
callback = kwargs.pop('callback')
|
||||
extra = kwargs.pop('extra', dict())
|
||||
|
||||
def process():
|
||||
try:
|
||||
result = fn(*args, **kwargs)
|
||||
except Exception as e:
|
||||
callback(None, e, **extra)
|
||||
else:
|
||||
callback(result, None, **extra)
|
||||
|
||||
Thread(target=process).start()
|
||||
return wrapper
|
||||
|
||||
|
||||
def synchronized(fn):
|
||||
lock = Lock()
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
lock.acquire()
|
||||
return fn(*args, **kwargs)
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class GP(object):
|
||||
def __init__(self):
|
||||
self.mc = Mobileclient()
|
||||
self.invalidate_caches()
|
||||
|
||||
def invalidate_caches(self):
|
||||
self.cached_songs = None
|
||||
self.cached_playlists = None
|
||||
|
||||
@async
|
||||
@synchronized
|
||||
def login(self, email, password, device_id):
|
||||
self.mc.logout()
|
||||
self.invalidate_caches()
|
||||
# TODO: Move device_id to settings
|
||||
return self.mc.login(email, password, device_id)
|
||||
|
||||
@async
|
||||
@synchronized
|
||||
def get_all_songs(self):
|
||||
if self.cached_songs:
|
||||
return self.cached_songs
|
||||
self.cached_songs = self.mc.get_all_songs()
|
||||
return self.cached_songs
|
||||
|
||||
@async
|
||||
def get_stream_url(self, id):
|
||||
return self.mc.get_stream_url(id)
|
||||
|
||||
@async
|
||||
@synchronized
|
||||
def get_all_user_playlist_contents(self):
|
||||
if self.cached_playlists:
|
||||
return self.cached_playlists
|
||||
if not self.cached_songs:
|
||||
self.cached_songs = self.mc.get_all_songs()
|
||||
cached_songs_map = {track['id']: track for track in self.cached_songs}
|
||||
self.cached_playlists = self.mc.get_all_user_playlist_contents()
|
||||
for playlist in self.cached_playlists:
|
||||
for song in playlist['tracks']:
|
||||
if 'track' not in song:
|
||||
song['track'] = cached_songs_map[song['trackId']]
|
||||
else:
|
||||
song['track']['id'] = song['trackId']
|
||||
return self.cached_playlists
|
||||
|
||||
|
||||
gp = GP()
|
||||
|
BIN
images/clay.png
Normal file
BIN
images/clay.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 97 KiB |
2
meta.py
Normal file
2
meta.py
Normal file
|
@ -0,0 +1,2 @@
|
|||
VERSION = '0.1b'
|
||||
|
27
mylibrary.py
Normal file
27
mylibrary.py
Normal file
|
@ -0,0 +1,27 @@
|
|||
import urwid
|
||||
from gp import gp
|
||||
from songlist import SongListBox
|
||||
from player import Track
|
||||
|
||||
|
||||
class MyLibrary(urwid.Columns):
|
||||
name = 'Library'
|
||||
key = 1
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
self.songlist = SongListBox(app)
|
||||
|
||||
gp.get_all_songs(callback=self.on_get_all_songs)
|
||||
|
||||
return super().__init__([
|
||||
self.songlist
|
||||
])
|
||||
|
||||
def on_get_all_songs(self, results, error):
|
||||
if error:
|
||||
self.app.set_page('Error', error)
|
||||
return
|
||||
self.songlist.populate(Track.from_data(results, many=True))
|
||||
self.app.redraw()
|
||||
|
98
myplaylists.py
Normal file
98
myplaylists.py
Normal file
|
@ -0,0 +1,98 @@
|
|||
import urwid
|
||||
from gp import gp
|
||||
from player import Track
|
||||
from songlist import SongListBox
|
||||
|
||||
|
||||
class PlaylistListItem(urwid.Columns):
|
||||
signals = ['activate']
|
||||
|
||||
def __init__(self, data):
|
||||
self.data = data
|
||||
self.content = urwid.AttrWrap(
|
||||
urwid.SelectableIcon(' {} ({})'.format(
|
||||
self.data['name'],
|
||||
len(self.data['tracks'])
|
||||
), cursor_position=3),
|
||||
'default',
|
||||
'selected'
|
||||
)
|
||||
super().__init__([self.content])
|
||||
|
||||
def keypress(self, size, key):
|
||||
if key == 'enter':
|
||||
urwid.emit_signal(self, 'activate', self)
|
||||
return
|
||||
return super().keypress(size, key)
|
||||
|
||||
def get_tracks(self):
|
||||
return Track.from_data([
|
||||
item['track']
|
||||
for item
|
||||
in self.data['tracks']
|
||||
], many=True)
|
||||
|
||||
|
||||
class PlaylistListBox(urwid.ListBox):
|
||||
signals = ['activate']
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
self.walker = urwid.SimpleListWalker([
|
||||
urwid.Text('Loading playlists...')
|
||||
])
|
||||
|
||||
gp.get_all_user_playlist_contents(callback=self.on_get_playlists)
|
||||
|
||||
super().__init__(self.walker)
|
||||
|
||||
def on_get_playlists(self, playlists, error):
|
||||
if error:
|
||||
self.app.set_page(
|
||||
'Error',
|
||||
str(error)
|
||||
)
|
||||
return
|
||||
|
||||
items = []
|
||||
for playlist in playlists:
|
||||
playlistlistitem = PlaylistListItem(playlist)
|
||||
urwid.connect_signal(
|
||||
playlistlistitem, 'activate', self.item_activated
|
||||
)
|
||||
items.append(playlistlistitem)
|
||||
|
||||
self.walker[:] = items
|
||||
|
||||
self.app.redraw()
|
||||
|
||||
def item_activated(self, playlistlistitem):
|
||||
urwid.emit_signal(self, 'activate', playlistlistitem)
|
||||
|
||||
|
||||
class MyPlaylists(urwid.Columns):
|
||||
name = 'Playlists'
|
||||
key = 2
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
self.playlistlist = PlaylistListBox(app)
|
||||
self.songlist = SongListBox(app)
|
||||
self.songlist.populate([])
|
||||
|
||||
urwid.connect_signal(
|
||||
self.playlistlist, 'activate', self.playlistlistitem_activated
|
||||
)
|
||||
|
||||
return super().__init__([
|
||||
self.playlistlist,
|
||||
self.songlist
|
||||
])
|
||||
|
||||
def playlistlistitem_activated(self, playlistlistitem):
|
||||
self.songlist.populate(
|
||||
playlistlistitem.get_tracks()
|
||||
)
|
||||
|
66
pages.py
Normal file
66
pages.py
Normal file
|
@ -0,0 +1,66 @@
|
|||
import urwid
|
||||
from settings import Settings
|
||||
from gp import gp
|
||||
from meta import VERSION
|
||||
|
||||
|
||||
class StartUp(urwid.Filler):
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
if Settings.is_config_valid():
|
||||
config = Settings.get_config()
|
||||
gp.login(
|
||||
config['username'],
|
||||
config['password'],
|
||||
config['device_id'],
|
||||
callback=self.on_login
|
||||
)
|
||||
else:
|
||||
self.app.set_page(
|
||||
'Error',
|
||||
'Please set your credentials on the settings page.'
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
urwid.Pile([
|
||||
urwid.Padding(
|
||||
urwid.AttrWrap(urwid.BigText(
|
||||
'Clay'.format(VERSION),
|
||||
urwid.font.HalfBlock5x4Font()
|
||||
), 'logo'),
|
||||
'center',
|
||||
None
|
||||
),
|
||||
urwid.AttrWrap(urwid.Text('Version {}'.format(VERSION), align='center'), 'line1'),
|
||||
urwid.AttrWrap(urwid.Text('Authorizing...', align='center'), 'line2')
|
||||
])
|
||||
# urwid.Text('Loading...'),
|
||||
# valign='top'
|
||||
)
|
||||
|
||||
def on_login(self, success, error):
|
||||
if error:
|
||||
self.app.set_page(
|
||||
'Error',
|
||||
'Failed to log in: {}'.format(str(error))
|
||||
)
|
||||
return
|
||||
if not success:
|
||||
self.app.set_page(
|
||||
'Error',
|
||||
'Google Play Music login failed '
|
||||
'(API returned false)'
|
||||
)
|
||||
return
|
||||
|
||||
self.app.set_page('MyLibrary')
|
||||
|
||||
|
||||
class Error(urwid.Filler):
|
||||
def __init__(self, app, error):
|
||||
super().__init__(
|
||||
urwid.Text('Error:\n\n{}'.format(str(error))),
|
||||
valign='top'
|
||||
)
|
||||
|
218
player.py
Normal file
218
player.py
Normal file
|
@ -0,0 +1,218 @@
|
|||
from random import randint
|
||||
|
||||
# import dbus
|
||||
import json
|
||||
|
||||
import vlc
|
||||
from eventhook import EventHook
|
||||
import gp
|
||||
from gp import gp
|
||||
|
||||
|
||||
class Track(object):
|
||||
def __init__(self, id, title, artist, duration):
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.artist = artist
|
||||
self.duration = duration
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data, many=False):
|
||||
if many:
|
||||
return [cls.from_data(one) for one in data]
|
||||
|
||||
return Track(
|
||||
id=data['id'],
|
||||
title=data['title'],
|
||||
artist=data['artist'],
|
||||
duration=int(data['durationMillis'])
|
||||
)
|
||||
|
||||
def get_url(self, callback):
|
||||
gp.get_stream_url(self.id, callback=callback, extra=dict(track=self))
|
||||
|
||||
|
||||
class Playlist(object):
|
||||
def __init__(self):
|
||||
self.random = False
|
||||
self.repeat_one = False
|
||||
|
||||
self.tracks = []
|
||||
self.current_track = None
|
||||
|
||||
def load(self, tracks, current_track=None):
|
||||
self.tracks = tracks[:]
|
||||
if current_track is None and len(self.tracks):
|
||||
current_track = self.tracks[0]
|
||||
self.current_track = current_track
|
||||
|
||||
def get_current_track(self):
|
||||
if self.current_track is None:
|
||||
return None
|
||||
return self.tracks[self.current_track]
|
||||
|
||||
def next(self, force=False):
|
||||
if self.current_track is None:
|
||||
if not len(self.tracks):
|
||||
return None
|
||||
self.current_track = self.tracks[0]
|
||||
|
||||
if self.repeat_one and not force:
|
||||
return self.get_current_track()
|
||||
|
||||
if self.random:
|
||||
self.current_track = randint(0, len(self.tracks) - 1)
|
||||
return self.get_current_track()
|
||||
|
||||
self.current_track += 1
|
||||
if (self.current_track + 1) >= len(self.tracks):
|
||||
self.current_track = 0
|
||||
|
||||
return self.get_current_track()
|
||||
|
||||
def get_tracks(self):
|
||||
return self.tracks
|
||||
|
||||
|
||||
class Player(object):
|
||||
media_position_changed = EventHook()
|
||||
media_state_changed = EventHook()
|
||||
track_changed = EventHook()
|
||||
playback_flags_changed = EventHook()
|
||||
|
||||
def __init__(self):
|
||||
self.mp = vlc.MediaPlayer()
|
||||
# self.bus = dbus.SessionBus()
|
||||
# try:
|
||||
# self.obj = self.bus.get_object('org.awesomewm.awful', '/org/dunai/clay')
|
||||
# except dbus.DBusException as e:
|
||||
# print(e)
|
||||
# self.interface = None
|
||||
# else:
|
||||
# self.interface = dbus.Interface(self.obj, 'org.dunai.clay')
|
||||
# self.statusfile = open('/tmp/clay.json', 'w')
|
||||
|
||||
self.mp.event_manager().event_attach(
|
||||
vlc.EventType.MediaPlayerPlaying,
|
||||
self._media_state_changed
|
||||
)
|
||||
self.mp.event_manager().event_attach(
|
||||
vlc.EventType.MediaPlayerPaused,
|
||||
self._media_state_changed
|
||||
)
|
||||
self.mp.event_manager().event_attach(
|
||||
vlc.EventType.MediaPlayerEndReached,
|
||||
self._media_end_reached
|
||||
)
|
||||
self.mp.event_manager().event_attach(
|
||||
vlc.EventType.MediaPlayerPositionChanged,
|
||||
self._media_position_changed
|
||||
)
|
||||
|
||||
self.playlist = Playlist()
|
||||
|
||||
def broadcast_state(self):
|
||||
track = self.playlist.get_current_track()
|
||||
if track is None:
|
||||
data = dict(
|
||||
playing=False,
|
||||
artist=None,
|
||||
title=None,
|
||||
progress=None,
|
||||
length=None
|
||||
)
|
||||
else:
|
||||
data = dict(
|
||||
playing=self.is_playing,
|
||||
artist=track.artist,
|
||||
title=track.title,
|
||||
progress=self.get_play_progress_seconds(),
|
||||
length=self.get_length_seconds()
|
||||
)
|
||||
with open('/tmp/clay.json', 'w') as f:
|
||||
f.write(json.dumps(data, indent=4))
|
||||
|
||||
def _media_state_changed(self, e):
|
||||
self.broadcast_state()
|
||||
self.media_state_changed.fire(self.is_playing)
|
||||
|
||||
def _media_end_reached(self, e):
|
||||
self.next()
|
||||
|
||||
def _media_position_changed(self, e):
|
||||
self.broadcast_state()
|
||||
self.media_position_changed.fire(
|
||||
self.get_play_progress()
|
||||
)
|
||||
|
||||
def load_playlist(self, data, current_index):
|
||||
self.playlist.load(data, current_index)
|
||||
self._play()
|
||||
|
||||
def get_is_random(self):
|
||||
return self.playlist.random
|
||||
|
||||
def get_is_repeat_one(self):
|
||||
return self.playlist.repeat_one
|
||||
|
||||
def set_random(self, value):
|
||||
self.playlist.random = value
|
||||
self.playback_flags_changed.fire()
|
||||
|
||||
def set_repeat_one(self, value):
|
||||
self.playlist.repeat_one = value
|
||||
self.playback_flags_changed.fire()
|
||||
|
||||
def get_queue(self):
|
||||
return self.playlist.get_tracks()
|
||||
|
||||
def _play(self):
|
||||
track = self.playlist.get_current_track()
|
||||
if track is None:
|
||||
return
|
||||
track.get_url(callback=self._play_ready)
|
||||
self.broadcast_state()
|
||||
self.track_changed.fire(track)
|
||||
|
||||
def _play_ready(self, url, error, track):
|
||||
if error:
|
||||
raise error
|
||||
self.mp.set_media(vlc.Media(url))
|
||||
self.mp.play()
|
||||
|
||||
@property
|
||||
def is_playing(self):
|
||||
return self.mp.get_state() == vlc.State.Playing
|
||||
|
||||
def play_pause(self):
|
||||
if self.is_playing:
|
||||
self.mp.pause()
|
||||
else:
|
||||
self.mp.play()
|
||||
|
||||
def get_play_progress(self):
|
||||
return self.mp.get_position()
|
||||
|
||||
def get_play_progress_seconds(self):
|
||||
return int(self.mp.get_position() * self.mp.get_length() / 1000)
|
||||
|
||||
def get_length_seconds(self):
|
||||
return int(self.mp.get_length() // 1000)
|
||||
|
||||
def next(self, force=False):
|
||||
self.playlist.next(force)
|
||||
self._play()
|
||||
|
||||
def get_current_track(self):
|
||||
return self.playlist.get_current_track()
|
||||
|
||||
# def prev(self):
|
||||
# self.playlist.prev()
|
||||
# self._play()
|
||||
|
||||
def seek(self, delta):
|
||||
self.mp.set_position(self.get_play_progress() + delta)
|
||||
|
||||
|
||||
player = Player()
|
||||
|
19
playerqueue.py
Normal file
19
playerqueue.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
import urwid
|
||||
from songlist import SongListBox
|
||||
from player import player
|
||||
|
||||
|
||||
class Queue(urwid.Columns):
|
||||
name = 'Queue'
|
||||
key = 3
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
self.songlist = SongListBox(app)
|
||||
|
||||
self.songlist.populate(player.get_queue())
|
||||
|
||||
return super().__init__([
|
||||
self.songlist
|
||||
])
|
||||
|
4
requirements.txt
Normal file
4
requirements.txt
Normal file
|
@ -0,0 +1,4 @@
|
|||
gmusicapi==10.1.2
|
||||
PyYAML==3.12
|
||||
urwid==1.3.1
|
||||
|
81
settings.py
Normal file
81
settings.py
Normal file
|
@ -0,0 +1,81 @@
|
|||
import os
|
||||
import yaml
|
||||
|
||||
import appdirs
|
||||
|
||||
import urwid
|
||||
|
||||
|
||||
class Settings(urwid.Columns):
|
||||
name = 'Settings'
|
||||
key = 9
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
config = self.__class__.get_config()
|
||||
self.username = urwid.Edit(
|
||||
edit_text=config.get('username', '')
|
||||
)
|
||||
self.password = urwid.Edit(
|
||||
mask='*', edit_text=config.get('password', '')
|
||||
)
|
||||
self.device_id = urwid.Edit(
|
||||
edit_text=config.get('device_id', '')
|
||||
)
|
||||
return super().__init__([urwid.ListBox(urwid.SimpleListWalker([
|
||||
urwid.Text('Settings'),
|
||||
urwid.Divider(' '),
|
||||
urwid.Text('Username'),
|
||||
urwid.AttrWrap(self.username, 'input', 'input_focus'),
|
||||
urwid.Divider(' '),
|
||||
urwid.Text('Password'),
|
||||
urwid.AttrWrap(self.password, 'input', 'input_focus'),
|
||||
urwid.Divider(' '),
|
||||
urwid.Text('Device ID'),
|
||||
urwid.AttrWrap(self.device_id, 'input', 'input_focus'),
|
||||
urwid.Divider(' '),
|
||||
urwid.AttrWrap(urwid.Button(
|
||||
'Save', on_press=self.on_save
|
||||
), 'input', 'input_focus')
|
||||
]))])
|
||||
|
||||
def on_save(self, button):
|
||||
self.__class__.set_config(dict(
|
||||
username=self.username.edit_text,
|
||||
password=self.password.edit_text,
|
||||
device_id=self.device_id.edit_text
|
||||
))
|
||||
self.app.set_page('StartUp')
|
||||
# self.app.set_page('MyLibrary')
|
||||
|
||||
@classmethod
|
||||
def get_config_filename(cls):
|
||||
filedir = appdirs.user_config_dir('clay', 'Clay')
|
||||
os.makedirs(filedir, exist_ok=True)
|
||||
path = os.path.join(filedir, 'config.json')
|
||||
if not os.path.exists(path):
|
||||
with open(path, 'w') as f:
|
||||
f.write('{}')
|
||||
return path
|
||||
|
||||
@classmethod
|
||||
def get_config(cls):
|
||||
with open(cls.get_config_filename(), 'r') as f:
|
||||
return yaml.load(f.read())
|
||||
|
||||
@classmethod
|
||||
def set_config(cls, new_config):
|
||||
config = cls.get_config()
|
||||
config.update(new_config)
|
||||
with open(cls.get_config_filename(), 'w') as f:
|
||||
f.write(yaml.dump(config, default_flow_style=False))
|
||||
|
||||
@classmethod
|
||||
def is_config_valid(cls):
|
||||
config = cls.get_config()
|
||||
return all([
|
||||
config.get(x, None)
|
||||
for x
|
||||
in ('username', 'password', 'device_id')
|
||||
])
|
||||
|
167
songlist.py
Normal file
167
songlist.py
Normal file
|
@ -0,0 +1,167 @@
|
|||
import urwid
|
||||
from player import player
|
||||
|
||||
|
||||
class SongListItem(urwid.Pile):
|
||||
signals = ['activate']
|
||||
|
||||
STATE_IDLE = 0
|
||||
STATE_LOADING = 1
|
||||
STATE_PLAYING = 2
|
||||
STATE_PAUSED = 3
|
||||
|
||||
STATE_ICONS = {
|
||||
0: ' ',
|
||||
1: '\uF141',
|
||||
2: '\uF04B',
|
||||
3: '\uF04C'
|
||||
}
|
||||
|
||||
def __init__(self, track, index):
|
||||
self.track = track
|
||||
self.index = index
|
||||
self.state = SongListItem.STATE_IDLE
|
||||
self.line1 = urwid.SelectableIcon('', cursor_position=6)
|
||||
self.line2 = urwid.AttrWrap(
|
||||
urwid.Text(''),
|
||||
'line2'
|
||||
)
|
||||
self.content = urwid.AttrWrap(
|
||||
urwid.Pile([
|
||||
self.line1,
|
||||
self.line2
|
||||
]),
|
||||
'line1',
|
||||
'line1_focus'
|
||||
)
|
||||
|
||||
super().__init__([
|
||||
self.content
|
||||
])
|
||||
self.update_text()
|
||||
|
||||
def set_state(self, state):
|
||||
self.state = state
|
||||
self.update_text()
|
||||
|
||||
def get_state_icon(self, state):
|
||||
return SongListItem.STATE_ICONS[state]
|
||||
|
||||
def update_text(self):
|
||||
self.line1.set_text(
|
||||
'{index:3d} {icon} {title} [{minutes:02d}:{seconds:02d}]'.format(
|
||||
index=self.index,
|
||||
icon=self.get_state_icon(self.state),
|
||||
title=self.track.title,
|
||||
minutes=self.track.duration // (1000 * 60),
|
||||
seconds=(self.track.duration // 1000) % 60
|
||||
)
|
||||
)
|
||||
self.line2.set_text(
|
||||
' {}'.format(self.track.artist)
|
||||
)
|
||||
if self.state == SongListItem.STATE_IDLE:
|
||||
self.content.set_attr('line1')
|
||||
self.content.set_focus_attr('line1_focus')
|
||||
else:
|
||||
self.content.set_attr('line1_active')
|
||||
self.content.set_focus_attr('line1_active_focus')
|
||||
|
||||
def keypress(self, size, key):
|
||||
if key == 'enter':
|
||||
urwid.emit_signal(self, 'activate', self)
|
||||
return
|
||||
return super().keypress(size, key)
|
||||
|
||||
# def render(self, size, focus=False):
|
||||
# # if focus:
|
||||
# # self.line1.attr = 'line1_focus'
|
||||
# # self.line2.attr = 'line2_focus'
|
||||
# # else:
|
||||
# # self.line1.attr = 'line1'
|
||||
# # self.line2.attr = 'line2'
|
||||
# urwid.Pile.render(self, size, focus)
|
||||
|
||||
|
||||
class SongListBox(urwid.ListBox):
|
||||
signals = ['activate']
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
self.current_item = None
|
||||
self.tracks = []
|
||||
self.walker = urwid.SimpleFocusListWalker([
|
||||
urwid.Text('\n Loading song list...', align='center')
|
||||
])
|
||||
|
||||
player.track_changed += self.track_changed
|
||||
player.media_state_changed += self.media_state_changed
|
||||
|
||||
return super().__init__(self.walker)
|
||||
|
||||
def tracks_to_songlist(self, tracks):
|
||||
current_track = player.get_current_track()
|
||||
items = []
|
||||
for index, track in enumerate(tracks):
|
||||
songitem = SongListItem(track, index)
|
||||
if current_track is not None and current_track.id == track.id:
|
||||
songitem.set_state(SongListItem.STATE_PLAYING)
|
||||
urwid.connect_signal(songitem, 'activate', self.item_activated)
|
||||
items.append(songitem)
|
||||
return items
|
||||
|
||||
def item_activated(self, songitem):
|
||||
player.load_playlist(self.tracks, songitem.index)
|
||||
|
||||
def track_changed(self, track):
|
||||
for songitem in self.walker:
|
||||
if songitem.track.id == track.id:
|
||||
songitem.set_state(SongListItem.STATE_PLAYING)
|
||||
elif songitem.state != SongListItem.STATE_IDLE:
|
||||
songitem.set_state(SongListItem.STATE_IDLE)
|
||||
|
||||
def media_state_changed(self, is_playing):
|
||||
current_track = player.get_current_track()
|
||||
if current_track is None:
|
||||
return
|
||||
|
||||
for songitem in self.walker:
|
||||
if songitem.track.id == current_track.id:
|
||||
songitem.set_state(
|
||||
SongListItem.STATE_PLAYING
|
||||
if is_playing
|
||||
else SongListItem.STATE_PAUSED
|
||||
)
|
||||
self.app.redraw()
|
||||
|
||||
# if self.current_item:
|
||||
# self.current_item.set_state(SongList.Song.State.IDLE)
|
||||
# self.current_item = item
|
||||
# item.set_state(SongList.Song.State.LOADING)
|
||||
# gp.get_stream_url(
|
||||
# item.data['id'],
|
||||
# callback=self.got_stream_url, extra=dict(item=item)
|
||||
# )
|
||||
|
||||
# def got_stream_url(self, url, e, item):
|
||||
# if item != self.current_item:
|
||||
# # Another song play requested while we were fetching stream URL
|
||||
# return
|
||||
# if e:
|
||||
# raise e
|
||||
# item.set_state(SongList.Song.State.PLAYING)
|
||||
# player.play(url)
|
||||
# # urwid.emit_signal(self, 'activate', item)
|
||||
|
||||
def populate(self, tracks):
|
||||
self.tracks = tracks
|
||||
self.walker[:] = self.tracks_to_songlist(self.tracks)
|
||||
# self.walker.set_focus(5)
|
||||
|
||||
# def keypress(self, size, key):
|
||||
# # print(key)
|
||||
# super().keypress(size, key)
|
||||
# # if key == 'up':
|
||||
# # self.walker.set_focus(
|
||||
|
Loading…
Add table
Reference in a new issue