mirror of
https://github.com/vale981/clay
synced 2025-03-05 09:31:40 -05:00
Forward merge to master
This commit is contained in:
commit
b6605b5576
22 changed files with 514 additions and 877 deletions
31
.codeclimate.yml
Normal file
31
.codeclimate.yml
Normal file
|
@ -0,0 +1,31 @@
|
|||
version: "2" # required to adjust maintainability checks
|
||||
exclude_patterns:
|
||||
- "clay/vlc.py"
|
||||
engines:
|
||||
duplication:
|
||||
enabled: false
|
||||
checks:
|
||||
argument-count:
|
||||
config:
|
||||
threshold: 8 # maximum complexity for ~ B mark in Radon
|
||||
complex-logic:
|
||||
config:
|
||||
threshold: 4
|
||||
file-lines:
|
||||
config:
|
||||
threshold: 1000
|
||||
method-complexity:
|
||||
config:
|
||||
threshold: 10
|
||||
method-count:
|
||||
config:
|
||||
threshold: 40
|
||||
method-lines:
|
||||
config:
|
||||
threshold: 40 # to fit in 1366x768 screen
|
||||
nested-control-flow:
|
||||
config:
|
||||
threshold: 4
|
||||
return-statements:
|
||||
config:
|
||||
threshold: 5
|
438
.pylintrc
438
.pylintrc
|
@ -1,430 +1,16 @@
|
|||
[MASTER]
|
||||
[pylint]
|
||||
max-line-length = 100
|
||||
max-args = 8
|
||||
|
||||
# A comma-separated list of package or module names from where C extensions may
|
||||
# be loaded. Extensions are loading into the active Python interpreter and may
|
||||
# run arbitrary code
|
||||
extension-pkg-whitelist=
|
||||
[messages control]
|
||||
disable =
|
||||
too-few-public-methods,
|
||||
too-many-public-methods,
|
||||
too-many-instance-attributes,
|
||||
no-self-use,
|
||||
too-many-ancestors
|
||||
|
||||
# Add files or directories to the blacklist. They should be base names, not
|
||||
# paths.
|
||||
ignore=CVS
|
||||
|
||||
# Add files or directories matching the regex patterns to the blacklist. The
|
||||
# regex matches against base names, not paths.
|
||||
ignore-patterns=
|
||||
|
||||
# Python code to execute, usually for sys.path manipulation such as
|
||||
# pygtk.require().
|
||||
#init-hook=
|
||||
|
||||
# Use multiple processes to speed up Pylint.
|
||||
jobs=1
|
||||
|
||||
# List of plugins (as comma separated values of python modules names) to load,
|
||||
# usually to register additional checkers.
|
||||
load-plugins=
|
||||
|
||||
# Pickle collected data for later comparisons.
|
||||
persistent=yes
|
||||
|
||||
# Specify a configuration file.
|
||||
#rcfile=
|
||||
|
||||
# Allow loading of arbitrary C extensions. Extensions are imported into the
|
||||
# active Python interpreter and may run arbitrary code.
|
||||
unsafe-load-any-extension=no
|
||||
|
||||
|
||||
[MESSAGES CONTROL]
|
||||
|
||||
# Only show warnings with the listed confidence levels. Leave empty to show
|
||||
# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED
|
||||
confidence=
|
||||
|
||||
# Disable the message, report, category or checker with the given id(s). You
|
||||
# can either give multiple identifiers separated by comma (,) or put this
|
||||
# option multiple times (only on the command line, not in the configuration
|
||||
# file where it should appear only once).You can also use "--disable=all" to
|
||||
# disable everything first and then reenable specific checks. For example, if
|
||||
# you want to run only the similarities checker, you can use "--disable=all
|
||||
# --enable=similarities". If you want to run only the classes checker, but have
|
||||
# no Warning level messages displayed, use"--disable=all --enable=classes
|
||||
# --disable=W"
|
||||
# disable=print-statement,parameter-unpacking,unpacking-in-except,old-raise-syntax,backtick,long-suffix,old-ne-operator,old-octal-literal,import-star-module-level,raw-checker-failed,bad-inline-option,locally-disabled,locally-enabled,file-ignored,suppressed-message,useless-suppression,deprecated-pragma,apply-builtin,basestring-builtin,buffer-builtin,cmp-builtin,coerce-builtin,execfile-builtin,file-builtin,long-builtin,raw_input-builtin,reduce-builtin,standarderror-builtin,unicode-builtin,xrange-builtin,coerce-method,delslice-method,getslice-method,setslice-method,no-absolute-import,old-division,dict-iter-method,dict-view-method,next-method-called,metaclass-assignment,indexing-exception,raising-string,reload-builtin,oct-method,hex-method,nonzero-method,cmp-method,input-builtin,round-builtin,intern-builtin,unichr-builtin,map-builtin-not-iterating,zip-builtin-not-iterating,range-builtin-not-iterating,filter-builtin-not-iterating,using-cmp-argument,eq-without-hash,div-method,idiv-method,rdiv-method,exception-message-attribute,invalid-str-codec,sys-max-int,bad-python3-import,deprecated-string-function,deprecated-str-translate-call
|
||||
|
||||
# Enable the message, report, category or checker with the given id(s). You can
|
||||
# either give multiple identifier separated by comma (,) or put this option
|
||||
# multiple time (only on the command line, not in the configuration file where
|
||||
# it should appear only once). See also the "--disable" option for examples.
|
||||
enable=
|
||||
|
||||
|
||||
[REPORTS]
|
||||
|
||||
# Python expression which should return a note less than 10 (10 is the highest
|
||||
# note). You have access to the variables errors warning, statement which
|
||||
# respectively contain the number of errors / warnings messages and the total
|
||||
# number of statements analyzed. This is used by the global evaluation report
|
||||
# (RP0004).
|
||||
evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)
|
||||
|
||||
# Template used to display messages. This is a python new-style format string
|
||||
# used to format the message information. See doc for all details
|
||||
#msg-template=
|
||||
|
||||
# Set the output format. Available formats are text, parseable, colorized, json
|
||||
# and msvs (visual studio).You can also give a reporter class, eg
|
||||
# mypackage.mymodule.MyReporterClass.
|
||||
output-format=text
|
||||
|
||||
# Tells whether to display a full report or only the messages
|
||||
reports=no
|
||||
|
||||
# Activate the evaluation score.
|
||||
score=yes
|
||||
|
||||
|
||||
[REFACTORING]
|
||||
|
||||
# Maximum number of nested blocks for function / method body
|
||||
max-nested-blocks=5
|
||||
|
||||
|
||||
[VARIABLES]
|
||||
|
||||
# List of additional names supposed to be defined in builtins. Remember that
|
||||
# you should avoid to define new builtins when possible.
|
||||
additional-builtins=
|
||||
|
||||
# Tells whether unused global variables should be treated as a violation.
|
||||
allow-global-unused-variables=yes
|
||||
|
||||
# List of strings which can identify a callback function by name. A callback
|
||||
# name must start or end with one of those strings.
|
||||
callbacks=cb_,_cb
|
||||
|
||||
# A regular expression matching the name of dummy variables (i.e. expectedly
|
||||
# not used).
|
||||
dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
|
||||
|
||||
# Argument names that match this expression will be ignored. Default to name
|
||||
# with leading underscore
|
||||
ignored-argument-names=_.*|^ignored_|^unused_
|
||||
|
||||
# Tells whether we should check for unused import in __init__ files.
|
||||
init-import=no
|
||||
|
||||
# List of qualified module names which can have objects that can redefine
|
||||
# builtins.
|
||||
redefining-builtins-modules=six.moves,future.builtins
|
||||
|
||||
|
||||
[TYPECHECK]
|
||||
|
||||
# List of decorators that produce context managers, such as
|
||||
# contextlib.contextmanager. Add to this list to register other decorators that
|
||||
# produce valid context managers.
|
||||
contextmanager-decorators=contextlib.contextmanager
|
||||
|
||||
# List of members which are set dynamically and missed by pylint inference
|
||||
# system, and so shouldn't trigger E1101 when accessed. Python regular
|
||||
# expressions are accepted.
|
||||
generated-members=
|
||||
|
||||
# Tells whether missing members accessed in mixin class should be ignored. A
|
||||
# mixin class is detected if its name ends with "mixin" (case insensitive).
|
||||
ignore-mixin-members=yes
|
||||
|
||||
# This flag controls whether pylint should warn about no-member and similar
|
||||
# checks whenever an opaque object is returned when inferring. The inference
|
||||
# can return multiple potential results while evaluating a Python object, but
|
||||
# some branches might not be evaluated, which results in partial inference. In
|
||||
# that case, it might be useful to still emit no-member and other checks for
|
||||
# the rest of the inferred objects.
|
||||
ignore-on-opaque-inference=yes
|
||||
|
||||
# List of class names for which member attributes should not be checked (useful
|
||||
# for classes with dynamically set attributes). This supports the use of
|
||||
# qualified names.
|
||||
ignored-classes=optparse.Values,thread._local,_thread._local
|
||||
|
||||
# List of module names for which member attributes should not be checked
|
||||
# (useful for modules/projects where namespaces are manipulated during runtime
|
||||
# and thus existing member attributes cannot be deduced by static analysis. It
|
||||
# supports qualified module names, as well as Unix pattern matching.
|
||||
ignored-modules=
|
||||
|
||||
# Show a hint with possible names when a member name was not found. The aspect
|
||||
# of finding the hint is based on edit distance.
|
||||
missing-member-hint=yes
|
||||
|
||||
# The minimum edit distance a name should have in order to be considered a
|
||||
# similar match for a missing member name.
|
||||
missing-member-hint-distance=1
|
||||
|
||||
# The total number of similar names that should be taken in consideration when
|
||||
# showing a hint for a missing member.
|
||||
missing-member-max-choices=1
|
||||
|
||||
|
||||
[BASIC]
|
||||
|
||||
# Naming hint for argument names
|
||||
argument-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$
|
||||
|
||||
# Regular expression matching correct argument names
|
||||
argument-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$
|
||||
|
||||
# Naming hint for attribute names
|
||||
attr-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$
|
||||
|
||||
# Regular expression matching correct attribute names
|
||||
attr-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$
|
||||
|
||||
# Bad variable names which should always be refused, separated by a comma
|
||||
bad-names=foo,bar,baz,toto,tutu,tata
|
||||
|
||||
# Naming hint for class attribute names
|
||||
class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$
|
||||
|
||||
# Regular expression matching correct class attribute names
|
||||
class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$
|
||||
|
||||
# Naming hint for class names
|
||||
class-name-hint=[A-Z_][a-zA-Z0-9]+$
|
||||
|
||||
# Regular expression matching correct class names
|
||||
class-rgx=[A-Z_][a-zA-Z0-9]+$
|
||||
|
||||
# Naming hint for constant names
|
||||
const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$
|
||||
|
||||
# Regular expression matching correct constant names
|
||||
const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$
|
||||
|
||||
# Minimum line length for functions/classes that require docstrings, shorter
|
||||
# ones are exempt.
|
||||
docstring-min-length=-1
|
||||
|
||||
# Naming hint for function names
|
||||
function-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$
|
||||
|
||||
# Regular expression matching correct function names
|
||||
function-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$
|
||||
|
||||
# Good variable names which should always be accepted, separated by a comma
|
||||
good-names=i,j,k,ex,Run,_
|
||||
|
||||
# Include a hint for the correct naming format with invalid-name
|
||||
include-naming-hint=no
|
||||
|
||||
# Naming hint for inline iteration names
|
||||
inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$
|
||||
|
||||
# Regular expression matching correct inline iteration names
|
||||
inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$
|
||||
|
||||
# Naming hint for method names
|
||||
method-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$
|
||||
|
||||
# Regular expression matching correct method names
|
||||
method-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$
|
||||
|
||||
# Naming hint for module names
|
||||
module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$
|
||||
|
||||
# Regular expression matching correct module names
|
||||
module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$
|
||||
|
||||
# Colon-delimited sets of names that determine each other's naming style when
|
||||
# the name regexes allow several styles.
|
||||
name-group=
|
||||
|
||||
# Regular expression which should only match function or class names that do
|
||||
# not require a docstring.
|
||||
no-docstring-rgx=^_
|
||||
|
||||
# List of decorators that produce properties, such as abc.abstractproperty. Add
|
||||
# to this list to register other decorators that produce valid properties.
|
||||
property-classes=abc.abstractproperty
|
||||
|
||||
# Naming hint for variable names
|
||||
variable-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$
|
||||
|
||||
# Regular expression matching correct variable names
|
||||
variable-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$
|
||||
|
||||
|
||||
[FORMAT]
|
||||
|
||||
# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
|
||||
expected-line-ending-format=
|
||||
|
||||
# Regexp for a line that is allowed to be longer than the limit.
|
||||
ignore-long-lines=^\s*(# )?<?https?://\S+>?$
|
||||
|
||||
# Number of spaces of indent required inside a hanging or continued line.
|
||||
indent-after-paren=4
|
||||
|
||||
# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
|
||||
# tab).
|
||||
indent-string=' '
|
||||
|
||||
# Maximum number of characters on a single line.
|
||||
max-line-length=100
|
||||
|
||||
# Maximum number of lines in a module
|
||||
max-module-lines=1000
|
||||
|
||||
# List of optional constructs for which whitespace checking is disabled. `dict-
|
||||
# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}.
|
||||
# `trailing-comma` allows a space between comma and closing bracket: (a, ).
|
||||
# `empty-line` allows space-only lines.
|
||||
no-space-check=trailing-comma,dict-separator
|
||||
|
||||
# Allow the body of a class to be on the same line as the declaration if body
|
||||
# contains single statement.
|
||||
single-line-class-stmt=no
|
||||
|
||||
# Allow the body of an if to be on the same line as the test if there is no
|
||||
# else.
|
||||
single-line-if-stmt=no
|
||||
|
||||
|
||||
[SIMILARITIES]
|
||||
|
||||
# Ignore comments when computing similarities.
|
||||
ignore-comments=yes
|
||||
|
||||
# Ignore docstrings when computing similarities.
|
||||
ignore-docstrings=yes
|
||||
|
||||
# Ignore imports when computing similarities.
|
||||
ignore-imports=yes
|
||||
|
||||
# Minimum lines number of a similarity.
|
||||
min-similarity-lines=4
|
||||
|
||||
|
||||
[MISCELLANEOUS]
|
||||
|
||||
[miscellaneous]
|
||||
# List of note tags to take in consideration, separated by a comma.
|
||||
notes=FIXME,XXX
|
||||
|
||||
|
||||
[SPELLING]
|
||||
|
||||
# Spelling dictionary name. Available dictionaries: en_GH (hunspell), en_IE
|
||||
# (hunspell), en_BW (hunspell), en_HK (hunspell), en_TT (hunspell), en_US
|
||||
# (hunspell), en_BZ (hunspell), en_NA (hunspell), he (hspell), uk (aspell),
|
||||
# en_NG (hunspell), en (aspell), en_CA (aspell), en_JM (hunspell), en_PH
|
||||
# (hunspell), en_AG (hunspell), en_IN (hunspell), en_ZW (hunspell), en_SG
|
||||
# (hunspell), en_GB (hunspell), en_DK (hunspell), en_ZA (hunspell), en_NZ
|
||||
# (hunspell), en_BS (hunspell).
|
||||
spelling-dict=
|
||||
|
||||
# List of comma separated words that should not be checked.
|
||||
spelling-ignore-words=
|
||||
|
||||
# A path to a file that contains private dictionary; one word per line.
|
||||
spelling-private-dict-file=
|
||||
|
||||
# Tells whether to store unknown words to indicated private dictionary in
|
||||
# --spelling-private-dict-file option instead of raising a message.
|
||||
spelling-store-unknown-words=no
|
||||
|
||||
|
||||
[LOGGING]
|
||||
|
||||
# Logging modules to check that the string format arguments are in logging
|
||||
# function parameter format
|
||||
logging-modules=logging
|
||||
|
||||
|
||||
[DESIGN]
|
||||
|
||||
# Maximum number of arguments for function / method
|
||||
max-args=5
|
||||
|
||||
# Maximum number of attributes for a class (see R0902).
|
||||
max-attributes=7
|
||||
|
||||
# Maximum number of boolean expressions in a if statement
|
||||
max-bool-expr=5
|
||||
|
||||
# Maximum number of branch for function / method body
|
||||
max-branches=12
|
||||
|
||||
# Maximum number of locals for function / method body
|
||||
max-locals=15
|
||||
|
||||
# Maximum number of parents for a class (see R0901).
|
||||
max-parents=7
|
||||
|
||||
# Maximum number of public methods for a class (see R0904).
|
||||
max-public-methods=20
|
||||
|
||||
# Maximum number of return / yield for function / method body
|
||||
max-returns=6
|
||||
|
||||
# Maximum number of statements in function / method body
|
||||
max-statements=50
|
||||
|
||||
# Minimum number of public methods for a class (see R0903).
|
||||
min-public-methods=2
|
||||
|
||||
|
||||
[CLASSES]
|
||||
|
||||
# List of method names used to declare (i.e. assign) instance attributes.
|
||||
defining-attr-methods=__init__,__new__,setUp
|
||||
|
||||
# List of member names, which should be excluded from the protected access
|
||||
# warning.
|
||||
exclude-protected=_asdict,_fields,_replace,_source,_make
|
||||
|
||||
# List of valid names for the first argument in a class method.
|
||||
valid-classmethod-first-arg=cls
|
||||
|
||||
# List of valid names for the first argument in a metaclass class method.
|
||||
valid-metaclass-classmethod-first-arg=mcs
|
||||
|
||||
|
||||
[IMPORTS]
|
||||
|
||||
# Allow wildcard imports from modules that define __all__.
|
||||
allow-wildcard-with-all=no
|
||||
|
||||
# Analyse import fallback blocks. This can be used to support both Python 2 and
|
||||
# 3 compatible code, which means that the block might have code that exists
|
||||
# only in one or another interpreter, leading to false positives when analysed.
|
||||
analyse-fallback-blocks=no
|
||||
|
||||
# Deprecated modules which should not be used, separated by a comma
|
||||
deprecated-modules=optparse,tkinter.tix
|
||||
|
||||
# Create a graph of external dependencies in the given file (report RP0402 must
|
||||
# not be disabled)
|
||||
ext-import-graph=
|
||||
|
||||
# Create a graph of every (i.e. internal and external) dependencies in the
|
||||
# given file (report RP0402 must not be disabled)
|
||||
import-graph=
|
||||
|
||||
# Create a graph of internal dependencies in the given file (report RP0402 must
|
||||
# not be disabled)
|
||||
int-import-graph=
|
||||
|
||||
# Force import order to recognize a module as part of the standard
|
||||
# compatibility libraries.
|
||||
known-standard-library=
|
||||
|
||||
# Force import order to recognize a module as part of a third party library.
|
||||
known-third-party=enchant
|
||||
|
||||
|
||||
[EXCEPTIONS]
|
||||
|
||||
# Exceptions that will emit a warning when being caught. Defaults to
|
||||
# "Exception"
|
||||
overgeneral-exceptions=Exception
|
||||
notes=
|
||||
|
|
|
@ -9,7 +9,7 @@ before_install:
|
|||
- "sudo apt-get update"
|
||||
- "sudo apt-get install python-gi python3-gi"
|
||||
install:
|
||||
- "pip install tox"
|
||||
- "pip install tox radon"
|
||||
script:
|
||||
- "tox"
|
||||
|
||||
|
|
3
Makefile
3
Makefile
|
@ -24,3 +24,6 @@ run: | build
|
|||
docs:
|
||||
make -C docs html
|
||||
|
||||
check:
|
||||
pylint clay --ignore-imports=y
|
||||
radon cc -a -s -nC -e clay/vlc.py clay
|
||||
|
|
|
@ -22,7 +22,7 @@
|
|||
|
||||
# Clay [beta]
|
||||
|
||||
[](https://travis-ci.org/and3rson/clay) [](http://clay.readthedocs.io/en/latest/?badge=latest)
|
||||
[](https://travis-ci.org/and3rson/clay) [](http://clay.readthedocs.io/en/latest/?badge=latest) [](https://badge.fury.io/py/clay-player) [](https://codeclimate.com/github/and3rson/clay/maintainability)
|
||||
|
||||
Standalone command line player for Google Play Music.
|
||||
|
||||
|
@ -32,6 +32,11 @@ This project is neither affiliated nor endorsed by Google.
|
|||
|
||||
It's being actively developed, but is still in the early beta stage, so many features are missing and/or may be bugged.
|
||||
|
||||
We're on IRC!
|
||||
|
||||
- Server: irc.oftc.net
|
||||
- Channel: **#clay**
|
||||
|
||||
Screenshot:
|
||||
|
||||

|
||||
|
|
75
clay/app.py
75
clay/app.py
|
@ -1,7 +1,5 @@
|
|||
#!/usr/bin/env python3
|
||||
# pylint: disable=wrong-import-position
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
# pylint: disable=too-many-public-methods
|
||||
"""
|
||||
Main app entrypoint.
|
||||
"""
|
||||
|
@ -15,7 +13,7 @@ import os
|
|||
import urwid
|
||||
|
||||
from clay import meta
|
||||
from clay.player import Player
|
||||
from clay.player import player
|
||||
from clay.playbar import PlayBar
|
||||
from clay.pages.debug import DebugPage
|
||||
from clay.pages.mylibrary import MyLibraryPage
|
||||
|
@ -23,18 +21,17 @@ from clay.pages.myplaylists import MyPlaylistsPage
|
|||
from clay.pages.playerqueue import QueuePage
|
||||
from clay.pages.search import SearchPage
|
||||
from clay.pages.settings import SettingsPage
|
||||
from clay.settings import Settings
|
||||
from clay.notifications import NotificationArea
|
||||
from clay.gp import GP
|
||||
from clay.settings import settings
|
||||
from clay.notifications import notification_area
|
||||
from clay.gp import gp
|
||||
|
||||
|
||||
def create_palette(transparent=False):
|
||||
config = Settings.get_config('colours')
|
||||
config = settings.get_section('colours')
|
||||
|
||||
return [(name, '', '', '', config[name]['foreground'], config[name]['background'])
|
||||
for name in config]
|
||||
|
||||
|
||||
class AppWidget(urwid.Frame):
|
||||
"""
|
||||
Root widget.
|
||||
|
@ -104,35 +101,25 @@ class AppWidget(urwid.Frame):
|
|||
self.current_page = None
|
||||
self.loop = None
|
||||
|
||||
NotificationArea.set_app(self)
|
||||
notification_area.set_app(self)
|
||||
self._login_notification = None
|
||||
|
||||
self._cancel_actions = []
|
||||
|
||||
self.header = urwid.Pile([
|
||||
# urwid.Divider('\u2500'),
|
||||
urwid.AttrWrap(urwid.Columns([
|
||||
('pack', tab)
|
||||
for tab
|
||||
in self.tabs
|
||||
], dividechars=0), 'panel'),
|
||||
NotificationArea.get()
|
||||
# urwid.Divider('\u2500')
|
||||
notification_area
|
||||
])
|
||||
self.playbar = PlayBar(self)
|
||||
# self.panel = urwid.Pile([
|
||||
# urwid.Columns([
|
||||
# urwid.Divider(u'\u2500'),
|
||||
# ]),
|
||||
# self.playbar
|
||||
# ])
|
||||
# self.current_page = self.pages[0]
|
||||
super(AppWidget, self).__init__(
|
||||
header=self.header,
|
||||
footer=self.playbar,
|
||||
body=urwid.Filler(urwid.Text('Loading...', align='center'))
|
||||
)
|
||||
# self.current_page.activate()
|
||||
|
||||
self.set_page('MyLibraryPage')
|
||||
self.log_in()
|
||||
|
@ -143,31 +130,30 @@ class AppWidget(urwid.Frame):
|
|||
|
||||
Request user authorization.
|
||||
"""
|
||||
config = Settings.get_config('play_settings')
|
||||
username, password, device_id, authtoken = [
|
||||
config.get(x)
|
||||
settings.get(x)
|
||||
for x
|
||||
in ('username', 'password', 'device_id', 'authtoken')
|
||||
]
|
||||
if self._login_notification:
|
||||
self._login_notification.close()
|
||||
if use_token and authtoken:
|
||||
self._login_notification = NotificationArea.notify('Using cached auth token...')
|
||||
GP.get().use_authtoken_async(
|
||||
self._login_notification = notification_area.notify('Using cached auth token...')
|
||||
gp.use_authtoken_async(
|
||||
authtoken,
|
||||
device_id,
|
||||
callback=self.on_check_authtoken
|
||||
)
|
||||
elif username and password and device_id:
|
||||
self._login_notification = NotificationArea.notify('Logging in...')
|
||||
GP.get().login_async(
|
||||
self._login_notification = notification_area.notify('Logging in...')
|
||||
gp.login_async(
|
||||
username,
|
||||
password,
|
||||
device_id,
|
||||
callback=self.on_login
|
||||
)
|
||||
else:
|
||||
self._login_notification = NotificationArea.notify(
|
||||
self._login_notification = notification_area.notify(
|
||||
'Please set your credentials on the settings page.'
|
||||
)
|
||||
|
||||
|
@ -205,7 +191,8 @@ class AppWidget(urwid.Frame):
|
|||
)
|
||||
return
|
||||
|
||||
Settings.set_config(dict(authtoken=GP.get().get_authtoken()))
|
||||
with settings.edit() as config:
|
||||
config['authtoken'] = gp.get_authtoken()
|
||||
|
||||
self._login_notification.close()
|
||||
|
||||
|
@ -275,42 +262,41 @@ class AppWidget(urwid.Frame):
|
|||
"""
|
||||
Seek to the start of the song.
|
||||
"""
|
||||
Player.get().seek_absolute(0)
|
||||
player.seek_absolute(0)
|
||||
|
||||
@staticmethod
|
||||
def play_pause():
|
||||
"""
|
||||
Toggle play/pause.
|
||||
"""
|
||||
Player.get().play_pause()
|
||||
player.play_pause()
|
||||
|
||||
@staticmethod
|
||||
def next_song():
|
||||
"""
|
||||
Play next song.
|
||||
"""
|
||||
Player.get().next(True)
|
||||
player.next(True)
|
||||
|
||||
@staticmethod
|
||||
def seek_backward():
|
||||
"""
|
||||
Seek 5% backward.
|
||||
"""
|
||||
Player.get().seek(-0.05)
|
||||
player.seek(-0.05)
|
||||
|
||||
@staticmethod
|
||||
def seek_forward():
|
||||
"""
|
||||
Seek 5% forward.
|
||||
"""
|
||||
Player.get().seek(0.05)
|
||||
player.seek(0.05)
|
||||
|
||||
@staticmethod
|
||||
def toggle_shuffle():
|
||||
"""
|
||||
Toggle random playback.
|
||||
"""
|
||||
player = Player.get()
|
||||
player.set_random(not player.get_is_random())
|
||||
|
||||
@staticmethod
|
||||
|
@ -318,7 +304,6 @@ class AppWidget(urwid.Frame):
|
|||
"""
|
||||
Toggle repeat mode.
|
||||
"""
|
||||
player = Player.get()
|
||||
player.set_repeat_one(not player.get_is_repeat_one())
|
||||
|
||||
@staticmethod
|
||||
|
@ -335,7 +320,7 @@ class AppWidget(urwid.Frame):
|
|||
try:
|
||||
action = self._cancel_actions.pop()
|
||||
except IndexError:
|
||||
NotificationArea.close_newest()
|
||||
notification_area.close_newest()
|
||||
else:
|
||||
action()
|
||||
|
||||
|
@ -357,24 +342,30 @@ class MultilineVersionAction(argparse.Action):
|
|||
|
||||
|
||||
def main():
|
||||
# This method is required to allow Clay to be ran as script via setuptools installation.
|
||||
# pylint: disable-all
|
||||
"""
|
||||
Application entrypoint.
|
||||
|
||||
This method is required to allow Clay to be ran an application when installed via setuptools.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog=meta.APP_NAME,
|
||||
description=meta.DESCRIPTION,
|
||||
epilog="This project is neither affiliated nor endorsed by Google.")
|
||||
epilog="This project is neither affiliated nor endorsed by Google."
|
||||
)
|
||||
|
||||
parser.add_argument("-v", "--version", action=MultilineVersionAction)
|
||||
|
||||
parser.add_argument(
|
||||
"--with-x-keybinds",
|
||||
help="define global X keybinds (requires Keybinder and PyGObject)",
|
||||
action='store_true')
|
||||
action='store_true'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--transparent",
|
||||
help="use transparent background",
|
||||
action='store_true')
|
||||
action='store_true'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
|
@ -382,7 +373,7 @@ def main():
|
|||
exit(0)
|
||||
|
||||
if args.with_x_keybinds:
|
||||
Player.get().enable_xorg_bindings()
|
||||
player.enable_xorg_bindings()
|
||||
|
||||
# Run the actual program
|
||||
app_widget = AppWidget()
|
||||
|
|
|
@ -3,7 +3,7 @@ Clipboard utils.
|
|||
"""
|
||||
from subprocess import Popen, PIPE
|
||||
|
||||
from clay.notifications import NotificationArea
|
||||
from clay.notifications import notification_area
|
||||
|
||||
|
||||
COMMANDS = [
|
||||
|
@ -24,7 +24,7 @@ def copy(text):
|
|||
if proc.returncode == 0:
|
||||
return True
|
||||
|
||||
NotificationArea.notify(
|
||||
notification_area.notify(
|
||||
'Failed to copy text to clipboard. '
|
||||
'Please install "xclip" or "xsel".'
|
||||
)
|
||||
|
|
205
clay/gp.py
205
clay/gp.py
|
@ -14,7 +14,7 @@ from uuid import UUID
|
|||
from gmusicapi.clients import Mobileclient
|
||||
|
||||
from clay.eventhook import EventHook
|
||||
from clay.log import Logger
|
||||
from clay.log import logger
|
||||
|
||||
|
||||
def asynchronous(func):
|
||||
|
@ -117,6 +117,13 @@ class Track(object):
|
|||
return self.library_id
|
||||
return self.store_id
|
||||
|
||||
@property
|
||||
def filename(self):
|
||||
"""
|
||||
Return a filename for this track.
|
||||
"""
|
||||
return self.store_id + '.mp3'
|
||||
|
||||
def __eq__(self, other):
|
||||
return (
|
||||
(self.library_id and self.library_id == other.library_id) or
|
||||
|
@ -124,6 +131,100 @@ class Track(object):
|
|||
(self.playlist_item_id and self.playlist_item_id == other.playlist_item_id)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _from_search(cls, data):
|
||||
"""
|
||||
Create track from search result data.
|
||||
"""
|
||||
# Data contains a nested track representation.
|
||||
return Track(
|
||||
title=data['track']['title'],
|
||||
artist=data['track']['artist'],
|
||||
duration=int(data['track']['durationMillis']),
|
||||
source=cls.SOURCE_SEARCH,
|
||||
store_id=data['track']['storeId'], # or data['trackId']
|
||||
album_name=data['track']['album'],
|
||||
album_url=data['track']['albumArtRef'][0]['url'],
|
||||
original_data=data
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _from_station(cls, data):
|
||||
"""
|
||||
Create track from station track data.
|
||||
"""
|
||||
# Station tracks have all the info in place.
|
||||
return Track(
|
||||
title=data['title'],
|
||||
artist=data['artist'],
|
||||
duration=int(data['durationMillis']),
|
||||
source=cls.SOURCE_STATION,
|
||||
store_id=data['storeId'],
|
||||
album_name=data['album'],
|
||||
album_url=data['albumArtRef'][0]['url'],
|
||||
original_data=data
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _from_library(cls, data):
|
||||
"""
|
||||
Create track from library track data.
|
||||
"""
|
||||
# Data contains all info about track
|
||||
# including ID in library and ID in store.
|
||||
UUID(data['id'])
|
||||
return Track(
|
||||
title=data['title'],
|
||||
artist=data['artist'],
|
||||
duration=int(data['durationMillis']),
|
||||
source=cls.SOURCE_LIBRARY,
|
||||
store_id=data['storeId'],
|
||||
library_id=data['id'],
|
||||
album_name=data['album'],
|
||||
album_url=data['albumArtRef'][0]['url'],
|
||||
original_data=data
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _from_playlist(cls, data):
|
||||
"""
|
||||
Create track from playlist track data.
|
||||
"""
|
||||
if 'track' in data:
|
||||
# Data contains a nested track representation that can be used
|
||||
# to construct new track.
|
||||
return Track(
|
||||
title=data['track']['title'],
|
||||
artist=data['track']['artist'],
|
||||
duration=int(data['track']['durationMillis']),
|
||||
source=cls.SOURCE_PLAYLIST,
|
||||
store_id=data['track']['storeId'], # or data['trackId']
|
||||
playlist_item_id=data['id'],
|
||||
album_name=data['track']['album'],
|
||||
album_url=data['track']['albumArtRef'][0]['url'],
|
||||
original_data=data
|
||||
)
|
||||
# We need to find a track in Library by trackId.
|
||||
UUID(data['trackId'])
|
||||
track = gp.get_track_by_id(data['trackId'])
|
||||
return Track(
|
||||
title=track.title,
|
||||
artist=track.artist,
|
||||
duration=track.duration,
|
||||
source=cls.SOURCE_PLAYLIST,
|
||||
store_id=track.store_id,
|
||||
album_name=track.album_name,
|
||||
album_url=track.album_url,
|
||||
original_data=data
|
||||
)
|
||||
|
||||
_CREATE_TRACK = {
|
||||
SOURCE_SEARCH: '_from_search',
|
||||
SOURCE_STATION: '_from_station',
|
||||
SOURCE_LIBRARY: '_from_library',
|
||||
SOURCE_PLAYLIST: '_from_playlist',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data, source, many=False):
|
||||
"""
|
||||
|
@ -139,75 +240,9 @@ class Track(object):
|
|||
]
|
||||
|
||||
try:
|
||||
if source == Track.SOURCE_SEARCH:
|
||||
# Data contains a nested track representation.
|
||||
return Track(
|
||||
title=data['track']['title'],
|
||||
artist=data['track']['artist'],
|
||||
duration=int(data['track']['durationMillis']),
|
||||
source=source,
|
||||
store_id=data['track']['storeId'], # or data['trackId']
|
||||
album_name=data['track']['album'],
|
||||
album_url=data['track']['albumArtRef'][0]['url'],
|
||||
original_data=data
|
||||
)
|
||||
elif source == Track.SOURCE_STATION:
|
||||
# Station tracks have all the info in place.
|
||||
return Track(
|
||||
title=data['title'],
|
||||
artist=data['artist'],
|
||||
duration=int(data['durationMillis']),
|
||||
source=source,
|
||||
store_id=data['storeId'],
|
||||
album_name=data['album'],
|
||||
album_url=data['albumArtRef'][0]['url'],
|
||||
original_data=data
|
||||
)
|
||||
elif source == Track.SOURCE_LIBRARY:
|
||||
# Data contains all info about track
|
||||
# including ID in library and ID in store.
|
||||
UUID(data['id'])
|
||||
return Track(
|
||||
title=data['title'],
|
||||
artist=data['artist'],
|
||||
duration=int(data['durationMillis']),
|
||||
source=source,
|
||||
store_id=data['storeId'],
|
||||
library_id=data['id'],
|
||||
album_name=data['album'],
|
||||
album_url=data['albumArtRef'][0]['url'],
|
||||
original_data=data
|
||||
)
|
||||
elif source == Track.SOURCE_PLAYLIST:
|
||||
if 'track' in data:
|
||||
# Data contains a nested track representation that can be used
|
||||
# to construct new track.
|
||||
return Track(
|
||||
title=data['track']['title'],
|
||||
artist=data['track']['artist'],
|
||||
duration=int(data['track']['durationMillis']),
|
||||
source=source,
|
||||
store_id=data['track']['storeId'], # or data['trackId']
|
||||
playlist_item_id=data['id'],
|
||||
album_name=data['track']['album'],
|
||||
album_url=data['track']['albumArtRef'][0]['url'],
|
||||
original_data=data
|
||||
)
|
||||
# We need to find a track in Library by trackId.
|
||||
UUID(data['trackId'])
|
||||
track = GP.get().get_track_by_id(data['trackId'])
|
||||
return Track(
|
||||
title=track.title,
|
||||
artist=track.artist,
|
||||
duration=track.duration,
|
||||
source=source,
|
||||
store_id=track.store_id,
|
||||
album_name=track.album_name,
|
||||
album_url=track.album_url,
|
||||
original_data=data
|
||||
)
|
||||
return getattr(cls, cls._CREATE_TRACK[source])(data)
|
||||
except Exception as error: # pylint: disable=bare-except
|
||||
Logger.get().error(
|
||||
logger.error(
|
||||
'Failed to parse track data: %s, failing data: %s',
|
||||
repr(error),
|
||||
data
|
||||
|
@ -241,11 +276,11 @@ class Track(object):
|
|||
self.cached_url = url
|
||||
callback(url, error, self)
|
||||
|
||||
if GP.get().is_subscribed:
|
||||
if gp.is_subscribed:
|
||||
track_id = self.store_id
|
||||
else:
|
||||
track_id = self.library_id
|
||||
GP.get().get_stream_url_async(track_id, callback=on_get_url)
|
||||
gp.get_stream_url_async(track_id, callback=on_get_url)
|
||||
|
||||
@synchronized
|
||||
def create_station(self):
|
||||
|
@ -254,7 +289,7 @@ class Track(object):
|
|||
|
||||
Returns :class:`.Station` instance.
|
||||
"""
|
||||
station_id = GP.get().mobile_client.create_station(
|
||||
station_id = gp.mobile_client.create_station(
|
||||
name=u'Station - {}'.format(self.title),
|
||||
track_id=self.store_id
|
||||
)
|
||||
|
@ -268,7 +303,7 @@ class Track(object):
|
|||
"""
|
||||
Add a track to my library.
|
||||
"""
|
||||
return GP.get().add_to_my_library(self)
|
||||
return gp.add_to_my_library(self)
|
||||
|
||||
add_to_my_library_async = asynchronous(add_to_my_library)
|
||||
|
||||
|
@ -276,7 +311,7 @@ class Track(object):
|
|||
"""
|
||||
Remove a track from my library.
|
||||
"""
|
||||
return GP.get().remove_from_my_library(self)
|
||||
return gp.remove_from_my_library(self)
|
||||
|
||||
remove_from_my_library_async = asynchronous(remove_from_my_library)
|
||||
|
||||
|
@ -341,7 +376,7 @@ class Station(object):
|
|||
Fetch tracks related to this station and
|
||||
populate it with :class:`Track` instances.
|
||||
"""
|
||||
data = GP.get().mobile_client.get_station_tracks(self.id, 100)
|
||||
data = gp.mobile_client.get_station_tracks(self.id, 100)
|
||||
self._tracks = Track.from_data(data, Track.SOURCE_STATION, many=True)
|
||||
self._tracks_loaded = True
|
||||
|
||||
|
@ -420,7 +455,7 @@ class Playlist(object):
|
|||
)
|
||||
|
||||
|
||||
class GP(object):
|
||||
class _GP(object):
|
||||
"""
|
||||
Interface to :class:`gmusicapi.Mobileclient`. Implements
|
||||
asynchronous API calls, caching and some other perks.
|
||||
|
@ -428,12 +463,9 @@ class GP(object):
|
|||
Singleton.
|
||||
"""
|
||||
# TODO: Switch to urwid signals for more explicitness?
|
||||
instance = None
|
||||
|
||||
caches_invalidated = EventHook()
|
||||
|
||||
def __init__(self):
|
||||
assert self.__class__.instance is None, 'Can be created only once!'
|
||||
# self.is_debug = os.getenv('CLAY_DEBUG')
|
||||
self.mobile_client = Mobileclient()
|
||||
self.mobile_client._make_call = self._make_call_proxy(
|
||||
|
@ -449,16 +481,6 @@ class GP(object):
|
|||
|
||||
self.auth_state_changed = EventHook()
|
||||
|
||||
@classmethod
|
||||
def get(cls):
|
||||
"""
|
||||
Create new :class:`.GP` instance or return existing one.
|
||||
"""
|
||||
if cls.instance is None:
|
||||
cls.instance = GP()
|
||||
|
||||
return cls.instance
|
||||
|
||||
def _make_call_proxy(self, func):
|
||||
"""
|
||||
Return a function that wraps *fn* and logs args & return values.
|
||||
|
@ -467,7 +489,7 @@ class GP(object):
|
|||
"""
|
||||
Wrapper function.
|
||||
"""
|
||||
Logger.get().debug('GP::{}(*{}, **{})'.format(
|
||||
logger.debug('GP::{}(*{}, **{})'.format(
|
||||
protocol.__name__,
|
||||
args,
|
||||
kwargs
|
||||
|
@ -633,3 +655,6 @@ class GP(object):
|
|||
Return True if user is subscribed on Google Play Music, false otherwise.
|
||||
"""
|
||||
return self.mobile_client.is_subscribed
|
||||
|
||||
|
||||
gp = _GP() # pylint: disable=invalid-name
|
||||
|
|
|
@ -5,16 +5,19 @@ Requires "gi" package and "Gtk" & "Keybinder" modules.
|
|||
# pylint: disable=broad-except
|
||||
import threading
|
||||
|
||||
from clay.settings import Settings
|
||||
from clay.settings import settings
|
||||
from clay.eventhook import EventHook
|
||||
from clay.notifications import NotificationArea
|
||||
from clay.log import Logger
|
||||
from clay.notifications import notification_area
|
||||
from clay.log import logger
|
||||
|
||||
|
||||
IS_INIT = False
|
||||
def report_error(error_msg):
|
||||
|
||||
|
||||
def report_error(exc):
|
||||
"Print an error message to the debug screen"
|
||||
Logger.get().error("{0}: {1}".format(error.__class__.__name__, error_msg))
|
||||
logger.error("{0}: {1}".format(exc.__class__.__name__, exc))
|
||||
|
||||
|
||||
try:
|
||||
# pylint: disable=import-error
|
||||
|
@ -36,7 +39,7 @@ else:
|
|||
IS_INIT = True
|
||||
|
||||
|
||||
class HotkeyManager(object):
|
||||
class _HotkeyManager(object):
|
||||
"""
|
||||
Manages configs.
|
||||
Runs Gtk main loop in a thread.
|
||||
|
@ -47,10 +50,7 @@ class HotkeyManager(object):
|
|||
'prev': 'XF86AudioPrev'
|
||||
}
|
||||
|
||||
instance = None
|
||||
|
||||
def __init__(self):
|
||||
assert self.__class__.instance is None, 'Can be created only once!'
|
||||
self.hotkeys = {}
|
||||
self.config = None
|
||||
|
||||
|
@ -64,29 +64,20 @@ class HotkeyManager(object):
|
|||
|
||||
threading.Thread(target=Gtk.main).start()
|
||||
else:
|
||||
Logger.get().debug("Not loading the global shortcuts.")
|
||||
NotificationArea.notify(ERROR_MESSAGE +
|
||||
", this means the global shortcuts will not work.\n" +
|
||||
"You can check the log for more details.")
|
||||
|
||||
@classmethod
|
||||
def get(cls):
|
||||
"""
|
||||
Create new :class:`.HotkeyManager` instance or return existing one.
|
||||
"""
|
||||
if cls.instance is None:
|
||||
cls.instance = HotkeyManager()
|
||||
|
||||
return cls.instance
|
||||
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 load_keys():
|
||||
"""
|
||||
Load hotkey config from settings.
|
||||
"""
|
||||
config = Settings.get_config('play_settings')
|
||||
hotkeys = config.get('hotkeys', {})
|
||||
for operation, default_key in HotkeyManager.DEFAULT_HOTKEYS.items():
|
||||
hotkeys = settings.get('hotkeys', default={})
|
||||
for operation, default_key in _HotkeyManager.DEFAULT_HOTKEYS.items():
|
||||
if operation not in hotkeys or not hotkeys[operation]:
|
||||
hotkeys[operation] = default_key
|
||||
return hotkeys
|
||||
|
@ -107,3 +98,6 @@ class HotkeyManager(object):
|
|||
"""
|
||||
assert key
|
||||
getattr(self, operation).fire()
|
||||
|
||||
|
||||
hotkey_manager = _HotkeyManager() # pylint: disable=invalid-name
|
||||
|
|
22
clay/log.py
22
clay/log.py
|
@ -8,7 +8,7 @@ from datetime import datetime
|
|||
from clay.eventhook import EventHook
|
||||
|
||||
|
||||
class LoggerRecord(object):
|
||||
class _LoggerRecord(object):
|
||||
"""
|
||||
Represents a logger record.
|
||||
"""
|
||||
|
@ -40,17 +40,14 @@ class LoggerRecord(object):
|
|||
return self._message % self._args
|
||||
|
||||
|
||||
class Logger(object):
|
||||
class _Logger(object):
|
||||
"""
|
||||
Global logger.
|
||||
|
||||
Allows subscribing to log events.
|
||||
"""
|
||||
instance = None
|
||||
|
||||
def __init__(self):
|
||||
assert self.__class__.instance is None, 'Can be created only once!'
|
||||
|
||||
self.logs = []
|
||||
self.logfile = open('/tmp/clay.log', 'w')
|
||||
|
||||
|
@ -58,23 +55,13 @@ class Logger(object):
|
|||
|
||||
self.on_log_event = EventHook()
|
||||
|
||||
@classmethod
|
||||
def get(cls):
|
||||
"""
|
||||
Create new :class:`.Logger` instance or return existing one.
|
||||
"""
|
||||
if cls.instance is None:
|
||||
cls.instance = Logger()
|
||||
|
||||
return cls.instance
|
||||
|
||||
def log(self, level, message, *args):
|
||||
"""
|
||||
Add log item.
|
||||
"""
|
||||
self._lock.acquire()
|
||||
try:
|
||||
logger_record = LoggerRecord(level, message, args)
|
||||
logger_record = _LoggerRecord(level, message, args)
|
||||
self.logs.append(logger_record)
|
||||
self.logfile.write('{} {:8} {}\n'.format(
|
||||
logger_record.formatted_timestamp,
|
||||
|
@ -117,3 +104,6 @@ class Logger(object):
|
|||
Return all logs.
|
||||
"""
|
||||
return self.logs
|
||||
|
||||
|
||||
logger = _Logger() # pylint: disable=invalid-name
|
||||
|
|
|
@ -4,7 +4,7 @@ Notification widgets.
|
|||
import urwid
|
||||
|
||||
|
||||
class Notification(urwid.Columns):
|
||||
class _Notification(urwid.Columns):
|
||||
"""
|
||||
Single notification widget.
|
||||
Can be updated or closed.
|
||||
|
@ -16,7 +16,7 @@ class Notification(urwid.Columns):
|
|||
self._id = notification_id
|
||||
self.text = urwid.Text('')
|
||||
self._set_text(message)
|
||||
super(Notification, self).__init__([
|
||||
super(_Notification, self).__init__([
|
||||
urwid.AttrWrap(
|
||||
urwid.Columns([
|
||||
self.text,
|
||||
|
@ -41,7 +41,7 @@ class Notification(urwid.Columns):
|
|||
message = '\n'.join([
|
||||
message[0]
|
||||
] + [' {}'.format(line) for line in message[1:]])
|
||||
self.text.set_text(Notification.TEMPLATE.format(message))
|
||||
self.text.set_text(_Notification.TEMPLATE.format(message))
|
||||
|
||||
def update(self, message):
|
||||
"""
|
||||
|
@ -50,7 +50,7 @@ class Notification(urwid.Columns):
|
|||
self._set_text(message)
|
||||
if not self.is_alive:
|
||||
self.area.append_notification(self)
|
||||
self.area.__class__.app.redraw()
|
||||
self.area.app.redraw()
|
||||
|
||||
@property
|
||||
def is_alive(self):
|
||||
|
@ -70,73 +70,36 @@ class Notification(urwid.Columns):
|
|||
if notification is self:
|
||||
self.area.contents.remove((notification, props))
|
||||
|
||||
if self.area.__class__.app is not None:
|
||||
self.area.__class__.app.redraw()
|
||||
if self.area.app is not None:
|
||||
self.area.app.redraw()
|
||||
|
||||
|
||||
class NotificationArea(urwid.Pile):
|
||||
class _NotificationArea(urwid.Pile):
|
||||
"""
|
||||
Notification area widget.
|
||||
"""
|
||||
instance = None
|
||||
app = None
|
||||
|
||||
def __init__(self):
|
||||
assert self.__class__.instance is None, 'Can be created only once!'
|
||||
self.app = None
|
||||
self.last_id = 0
|
||||
self.notifications = {}
|
||||
super(NotificationArea, self).__init__([])
|
||||
super(_NotificationArea, self).__init__([])
|
||||
|
||||
@classmethod
|
||||
def get(cls):
|
||||
"""
|
||||
Create new :class:`.NotificationArea` instance or return existing one.
|
||||
"""
|
||||
if cls.instance is None:
|
||||
cls.instance = NotificationArea()
|
||||
|
||||
return cls.instance
|
||||
|
||||
@classmethod
|
||||
def set_app(cls, app):
|
||||
def set_app(self, app):
|
||||
"""
|
||||
Set app instance.
|
||||
|
||||
Required for proper screen redraws when
|
||||
new notifications are created asynchronously.
|
||||
"""
|
||||
cls.app = app
|
||||
self.app = app
|
||||
|
||||
@classmethod
|
||||
def notify(cls, message):
|
||||
"""
|
||||
Create new notification with message.
|
||||
This is a class method.
|
||||
"""
|
||||
return cls.get().do_notify(message)
|
||||
|
||||
@classmethod
|
||||
def close_all(cls):
|
||||
"""
|
||||
Close all notfiications.
|
||||
This is a class method.
|
||||
"""
|
||||
cls.get().do_close_all()
|
||||
|
||||
@classmethod
|
||||
def close_newest(cls):
|
||||
"""
|
||||
Close newest notification.
|
||||
This is a class method.
|
||||
"""
|
||||
cls.get().do_close_newest()
|
||||
|
||||
def do_notify(self, message):
|
||||
def notify(self, message):
|
||||
"""
|
||||
Create new notification with message.
|
||||
"""
|
||||
self.last_id += 1
|
||||
notification = Notification(self, self.last_id, message)
|
||||
notification = _Notification(self, self.last_id, message)
|
||||
self.append_notification(notification)
|
||||
return notification
|
||||
|
||||
|
@ -150,20 +113,23 @@ class NotificationArea(urwid.Pile):
|
|||
('weight', 1)
|
||||
)
|
||||
)
|
||||
if self.__class__.app is not None:
|
||||
self.__class__.app.redraw()
|
||||
if self.app is not None:
|
||||
self.app.redraw()
|
||||
|
||||
def do_close_all(self):
|
||||
def close_all(self):
|
||||
"""
|
||||
Close all notifications.
|
||||
"""
|
||||
while self.contents:
|
||||
self.contents[0][0].close()
|
||||
|
||||
def do_close_newest(self):
|
||||
def close_newest(self):
|
||||
"""
|
||||
Close newest notification
|
||||
"""
|
||||
if not self.contents:
|
||||
return
|
||||
self.contents[-1][0].close()
|
||||
|
||||
|
||||
notification_area = _NotificationArea() # pylint: disable=invalid-name
|
||||
|
|
|
@ -4,9 +4,9 @@ Debug page.
|
|||
import urwid
|
||||
|
||||
from clay.pages.page import AbstractPage
|
||||
from clay.log import Logger
|
||||
from clay.log import logger
|
||||
from clay.clipboard import copy
|
||||
from clay.gp import GP
|
||||
from clay.gp import gp
|
||||
|
||||
|
||||
class DebugItem(urwid.AttrMap):
|
||||
|
@ -49,9 +49,9 @@ class DebugPage(urwid.Pile, AbstractPage):
|
|||
def __init__(self, app):
|
||||
self.app = app
|
||||
self.walker = urwid.SimpleListWalker([])
|
||||
for log_record in Logger.get().get_logs():
|
||||
for log_record in logger.get_logs():
|
||||
self._append_log(log_record)
|
||||
Logger.get().on_log_event += self._append_log
|
||||
logger.on_log_event += self._append_log
|
||||
self.listbox = urwid.ListBox(self.walker)
|
||||
|
||||
self.debug_data = urwid.Text('')
|
||||
|
@ -64,7 +64,7 @@ class DebugPage(urwid.Pile, AbstractPage):
|
|||
self.listbox
|
||||
])
|
||||
|
||||
GP.get().auth_state_changed += self.update
|
||||
gp.auth_state_changed += self.update
|
||||
|
||||
self.update()
|
||||
|
||||
|
@ -72,12 +72,11 @@ class DebugPage(urwid.Pile, AbstractPage):
|
|||
"""
|
||||
Update this widget.
|
||||
"""
|
||||
gpclient = GP.get()
|
||||
self.debug_data.set_text(
|
||||
'- Is authenticated: {}\n'
|
||||
'- Is subscribed: {}'.format(
|
||||
gpclient.is_authenticated,
|
||||
gpclient.is_subscribed if gpclient.is_authenticated else None
|
||||
gp.is_authenticated,
|
||||
gp.is_subscribed if gp.is_authenticated else None
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
@ -3,9 +3,9 @@ Library page.
|
|||
"""
|
||||
import urwid
|
||||
|
||||
from clay.gp import GP
|
||||
from clay.gp import gp
|
||||
from clay.songlist import SongListBox
|
||||
from clay.notifications import NotificationArea
|
||||
from clay.notifications import notification_area
|
||||
from clay.pages.page import AbstractPage
|
||||
|
||||
|
||||
|
@ -28,8 +28,8 @@ class MyLibraryPage(urwid.Columns, AbstractPage):
|
|||
self.songlist = SongListBox(app)
|
||||
self.notification = None
|
||||
|
||||
GP.get().auth_state_changed += self.get_all_songs
|
||||
GP.get().caches_invalidated += self.get_all_songs
|
||||
gp.auth_state_changed += self.get_all_songs
|
||||
gp.caches_invalidated += self.get_all_songs
|
||||
|
||||
super(MyLibraryPage, self).__init__([
|
||||
self.songlist
|
||||
|
@ -41,7 +41,7 @@ class MyLibraryPage(urwid.Columns, AbstractPage):
|
|||
Populate song list.
|
||||
"""
|
||||
if error:
|
||||
NotificationArea.notify('Failed to load my library: {}'.format(str(error)))
|
||||
notification_area.notify('Failed to load my library: {}'.format(str(error)))
|
||||
return
|
||||
# self.notification.close()
|
||||
self.songlist.populate(tracks)
|
||||
|
@ -51,12 +51,11 @@ class MyLibraryPage(urwid.Columns, AbstractPage):
|
|||
"""
|
||||
Called when auth state changes or GP caches are invalidated.
|
||||
"""
|
||||
if GP.get().is_authenticated:
|
||||
if gp.is_authenticated:
|
||||
self.songlist.set_placeholder(u'\n \uf01e Loading song list...')
|
||||
|
||||
GP.get().get_all_tracks_async(callback=self.on_get_all_songs)
|
||||
gp.get_all_tracks_async(callback=self.on_get_all_songs)
|
||||
self.app.redraw()
|
||||
# self.notification = NotificationArea.notify('Loading library...')
|
||||
|
||||
def activate(self):
|
||||
pass
|
||||
|
|
|
@ -3,9 +3,9 @@ Components for "My playlists" page.
|
|||
"""
|
||||
import urwid
|
||||
|
||||
from clay.gp import GP
|
||||
from clay.gp import gp
|
||||
from clay.songlist import SongListBox
|
||||
from clay.notifications import NotificationArea
|
||||
from clay.notifications import notification_area
|
||||
from clay.pages.page import AbstractPage
|
||||
|
||||
|
||||
|
@ -59,7 +59,7 @@ class MyPlaylistListBox(urwid.ListBox):
|
|||
])
|
||||
self.notification = None
|
||||
|
||||
GP.get().auth_state_changed += self.auth_state_changed
|
||||
gp.auth_state_changed += self.auth_state_changed
|
||||
|
||||
super(MyPlaylistListBox, self).__init__(self.walker)
|
||||
|
||||
|
@ -73,9 +73,7 @@ class MyPlaylistListBox(urwid.ListBox):
|
|||
urwid.Text(u'\n \uf01e Loading playlists...', align='center')
|
||||
]
|
||||
|
||||
GP.get().get_all_user_playlist_contents_async(callback=self.on_get_playlists)
|
||||
|
||||
# self.notification = NotificationArea.notify('Loading playlists...')
|
||||
gp.get_all_user_playlist_contents_async(callback=self.on_get_playlists)
|
||||
|
||||
def on_get_playlists(self, playlists, error):
|
||||
"""
|
||||
|
@ -83,7 +81,7 @@ class MyPlaylistListBox(urwid.ListBox):
|
|||
Populates list of playlists.
|
||||
"""
|
||||
if error:
|
||||
NotificationArea.notify('Failed to get playlists: {}'.format(str(error)))
|
||||
notification_area.notify('Failed to get playlists: {}'.format(str(error)))
|
||||
|
||||
items = []
|
||||
for playlist in playlists:
|
||||
|
|
|
@ -4,7 +4,7 @@ Components for "Queue" page.
|
|||
import urwid
|
||||
|
||||
from clay.songlist import SongListBox
|
||||
from clay.player import Player
|
||||
from clay.player import player
|
||||
from clay.pages.page import AbstractPage
|
||||
|
||||
|
||||
|
@ -24,7 +24,6 @@ class QueuePage(urwid.Columns, AbstractPage):
|
|||
self.app = app
|
||||
self.songlist = SongListBox(app)
|
||||
|
||||
player = Player.get()
|
||||
self.songlist.populate(player.get_queue_tracks())
|
||||
player.queue_changed += self.queue_changed
|
||||
player.track_appended += self.track_appended
|
||||
|
@ -39,7 +38,7 @@ class QueuePage(urwid.Columns, AbstractPage):
|
|||
Called when player queue is changed.
|
||||
Updates this queue widget.
|
||||
"""
|
||||
self.songlist.populate(Player.get().get_queue_tracks())
|
||||
self.songlist.populate(player.get_queue_tracks())
|
||||
|
||||
def track_appended(self, track):
|
||||
"""
|
||||
|
|
|
@ -3,9 +3,9 @@ Components for search page.
|
|||
"""
|
||||
import urwid
|
||||
|
||||
from clay.gp import GP
|
||||
from clay.gp import gp
|
||||
from clay.songlist import SongListBox
|
||||
from clay.notifications import NotificationArea
|
||||
from clay.notifications import notification_area
|
||||
from clay.pages.page import AbstractPage
|
||||
|
||||
|
||||
|
@ -76,14 +76,14 @@ class SearchPage(urwid.Pile, AbstractPage):
|
|||
self.songlist.set_placeholder(u' \U0001F50D Searching for "{}"...'.format(
|
||||
query
|
||||
))
|
||||
GP.get().search_async(query, callback=self.search_finished)
|
||||
gp.search_async(query, callback=self.search_finished)
|
||||
|
||||
def search_finished(self, results, error):
|
||||
"""
|
||||
Populate song list with search results.
|
||||
"""
|
||||
if error:
|
||||
NotificationArea.notify('Failed to search: {}'.format(str(error)))
|
||||
notification_area.notify('Failed to search: {}'.format(str(error)))
|
||||
else:
|
||||
self.songlist.populate(results.get_tracks())
|
||||
self.app.redraw()
|
||||
|
|
|
@ -4,8 +4,8 @@ Components for "Settings" page.
|
|||
import urwid
|
||||
|
||||
from clay.pages.page import AbstractPage
|
||||
from clay.settings import Settings
|
||||
from clay.player import Player
|
||||
from clay.settings import settings
|
||||
from clay.player import player
|
||||
|
||||
|
||||
class Slider(urwid.Widget):
|
||||
|
@ -31,9 +31,9 @@ class Slider(urwid.Widget):
|
|||
freq = int(freq)
|
||||
self.freq = freq
|
||||
if freq >= 1000:
|
||||
self.freq_str = str(freq // 1000) + ' KHz'
|
||||
self.freq_str = str(freq // 1000) + '\nKHz'
|
||||
else:
|
||||
self.freq_str = str(freq) + ' Hz'
|
||||
self.freq_str = str(freq) + '\nHz'
|
||||
self.value = 0
|
||||
self.slider_height = 5
|
||||
self.max_value = 20
|
||||
|
@ -43,7 +43,7 @@ class Slider(urwid.Widget):
|
|||
"""
|
||||
Return count of rows required to render this widget.
|
||||
"""
|
||||
return self.slider_height + 2
|
||||
return self.slider_height + 3
|
||||
|
||||
def render(self, size, focus=None):
|
||||
"""
|
||||
|
@ -95,7 +95,7 @@ class Slider(urwid.Widget):
|
|||
"""
|
||||
Update player equalizer & toggle redraw.
|
||||
"""
|
||||
Player.get().set_equalizer_value(self.index, self.value)
|
||||
player.set_equalizer_value(self.index, self.value)
|
||||
self._invalidate()
|
||||
|
||||
|
||||
|
@ -107,7 +107,7 @@ class Equalizer(urwid.Columns):
|
|||
self.bands = [
|
||||
Slider(index, freq)
|
||||
for index, freq
|
||||
in enumerate(Player.get().get_equalizer_freqs())
|
||||
in enumerate(player.get_equalizer_freqs())
|
||||
]
|
||||
super(Equalizer, self).__init__(
|
||||
self.bands
|
||||
|
@ -128,20 +128,18 @@ class SettingsPage(urwid.Columns, AbstractPage):
|
|||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
config = Settings.get_config('play_settings')
|
||||
|
||||
self.username = urwid.Edit(
|
||||
edit_text=config.get('username', '')
|
||||
edit_text=settings.get('username', default='')
|
||||
)
|
||||
self.password = urwid.Edit(
|
||||
mask='*', edit_text=config.get('password', '')
|
||||
mask='*', edit_text=settings.get('password', default='')
|
||||
)
|
||||
self.device_id = urwid.Edit(
|
||||
edit_text=config.get('device_id', '')
|
||||
edit_text=settings.get('device_id', default='')
|
||||
)
|
||||
self.download_tracks = urwid.CheckBox(
|
||||
'Download tracks before playback',
|
||||
state=config.get('download_tracks', False)
|
||||
state=settings.get('download_tracks', default=False)
|
||||
)
|
||||
self.equalizer = Equalizer()
|
||||
super(SettingsPage, self).__init__([urwid.ListBox(urwid.SimpleListWalker([
|
||||
|
@ -169,12 +167,12 @@ class SettingsPage(urwid.Columns, AbstractPage):
|
|||
"""
|
||||
Called when "Save" button is pressed.
|
||||
"""
|
||||
Settings.set_config(dict(
|
||||
username=self.username.edit_text,
|
||||
password=self.password.edit_text,
|
||||
device_id=self.device_id.edit_text,
|
||||
download_tracks=self.download_tracks.state
|
||||
))
|
||||
with settings.edit() as config:
|
||||
config['username'] = self.username.edit_text
|
||||
config['password'] = self.password.edit_text
|
||||
config['device_id'] = self.device_id.edit_text
|
||||
config['download_tracks'] = self.download_tracks.state
|
||||
|
||||
self.app.set_page('MyLibraryPage')
|
||||
self.app.log_in()
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ PlayBar widget.
|
|||
# pylint: disable=too-many-instance-attributes
|
||||
import urwid
|
||||
|
||||
from clay.player import Player
|
||||
from clay.player import player
|
||||
from clay import meta
|
||||
|
||||
|
||||
|
@ -98,7 +98,6 @@ class PlayBar(urwid.Pile):
|
|||
])
|
||||
self.update()
|
||||
|
||||
player = Player.get()
|
||||
player.media_position_changed += self.update
|
||||
player.media_state_changed += self.update
|
||||
player.track_changed += self.update
|
||||
|
@ -115,7 +114,6 @@ class PlayBar(urwid.Pile):
|
|||
"""
|
||||
Return the style for current playback state.
|
||||
"""
|
||||
player = Player.get()
|
||||
if player.is_loading or player.is_playing:
|
||||
return 'title-playing'
|
||||
return 'title-idle'
|
||||
|
@ -124,7 +122,6 @@ class PlayBar(urwid.Pile):
|
|||
"""
|
||||
Return text for display in this bar.
|
||||
"""
|
||||
player = Player.get()
|
||||
track = player.get_current_track()
|
||||
if track is None:
|
||||
return u'{} {}'.format(
|
||||
|
@ -153,7 +150,6 @@ class PlayBar(urwid.Pile):
|
|||
Called when something unrelated to completion value changes,
|
||||
e.g. current track or playback flags.
|
||||
"""
|
||||
player = Player.get()
|
||||
self.text.set_text(self.get_text())
|
||||
self.progressbar.set_progress(player.get_play_progress())
|
||||
self.progressbar.set_done_style(
|
||||
|
|
|
@ -14,11 +14,12 @@ except ImportError: # Python 2.x
|
|||
|
||||
from clay import vlc, meta
|
||||
from clay.eventhook import EventHook
|
||||
from clay.notifications import NotificationArea
|
||||
from clay.settings import Settings
|
||||
from clay.log import Logger
|
||||
from clay.notifications import notification_area
|
||||
from clay.settings import settings
|
||||
from clay.log import logger
|
||||
|
||||
class Queue(object):
|
||||
|
||||
class _Queue(object):
|
||||
"""
|
||||
Model that represents player queue (local playlist),
|
||||
i.e. list of tracks to be played.
|
||||
|
@ -111,15 +112,13 @@ class Queue(object):
|
|||
return self.tracks
|
||||
|
||||
|
||||
class Player(object):
|
||||
class _Player(object):
|
||||
"""
|
||||
Interface to libVLC. Uses Queue as a playback plan.
|
||||
Emits various events if playback state, tracks or play flags change.
|
||||
|
||||
Singleton.
|
||||
"""
|
||||
instance = None
|
||||
|
||||
media_position_changed = EventHook()
|
||||
media_state_changed = EventHook()
|
||||
track_changed = EventHook()
|
||||
|
@ -129,9 +128,6 @@ class Player(object):
|
|||
track_removed = EventHook()
|
||||
|
||||
def __init__(self):
|
||||
assert self.__class__.instance is None, 'Can be created only once!'
|
||||
self.logger = Logger.get()
|
||||
|
||||
self.instance = vlc.Instance()
|
||||
self.instance.set_user_agent(
|
||||
meta.APP_NAME,
|
||||
|
@ -163,26 +159,15 @@ class Player(object):
|
|||
|
||||
self._create_station_notification = None
|
||||
self._is_loading = False
|
||||
self.queue = Queue()
|
||||
|
||||
@classmethod
|
||||
def get(cls):
|
||||
"""
|
||||
Create new :class:`.Player` instance or return existing one.
|
||||
"""
|
||||
if cls.instance is None:
|
||||
cls.instance = Player()
|
||||
|
||||
return cls.instance
|
||||
self.queue = _Queue()
|
||||
|
||||
def enable_xorg_bindings(self):
|
||||
"""Enable the global X bindings using keybinder"""
|
||||
if os.environ.get("DISPLAY") is None:
|
||||
self.logger.debug("X11 isn't running so we can't load the global keybinds")
|
||||
logger.debug("X11 isn't running so we can't load the global keybinds")
|
||||
return
|
||||
|
||||
from clay.hotkeys import HotkeyManager
|
||||
hotkey_manager = HotkeyManager.get()
|
||||
from clay.hotkeys import hotkey_manager
|
||||
hotkey_manager.play_pause += self.play_pause
|
||||
hotkey_manager.next += self.next
|
||||
hotkey_manager.prev += lambda: self.seek_absolute(0)
|
||||
|
@ -247,7 +232,7 @@ class Player(object):
|
|||
Load queue & start playback.
|
||||
Fires :attr:`.queue_changed` event.
|
||||
|
||||
See :meth:`.Queue.load`.
|
||||
See :meth:`._Queue.load`.
|
||||
"""
|
||||
self.queue.load(data, current_index)
|
||||
self.queue_changed.fire()
|
||||
|
@ -258,7 +243,7 @@ class Player(object):
|
|||
Append track to queue.
|
||||
Fires :attr:`.track_appended` event.
|
||||
|
||||
See :meth:`.Queue.append`
|
||||
See :meth:`._Queue.append`
|
||||
"""
|
||||
self.queue.append(track)
|
||||
self.track_appended.fire(track)
|
||||
|
@ -269,7 +254,7 @@ class Player(object):
|
|||
Remove track from queue.
|
||||
Fires :attr:`.track_removed` event.
|
||||
|
||||
See :meth:`.Queue.remove`
|
||||
See :meth:`._Queue.remove`
|
||||
"""
|
||||
self.queue.remove(track)
|
||||
self.track_removed.fire(track)
|
||||
|
@ -279,7 +264,7 @@ class Player(object):
|
|||
Request creation of new station from some track.
|
||||
Runs in background.
|
||||
"""
|
||||
self._create_station_notification = NotificationArea.notify('Creating station...')
|
||||
self._create_station_notification = notification_area.notify('Creating station...')
|
||||
track.create_station_async(callback=self._create_station_from_track_ready)
|
||||
|
||||
def _create_station_from_track_ready(self, station, error):
|
||||
|
@ -330,7 +315,7 @@ class Player(object):
|
|||
|
||||
def get_queue_tracks(self):
|
||||
"""
|
||||
Return :attr:`.Queue.get_tracks`
|
||||
Return :attr:`._Queue.get_tracks`
|
||||
"""
|
||||
return self.queue.get_tracks()
|
||||
|
||||
|
@ -346,29 +331,30 @@ class Player(object):
|
|||
self.broadcast_state()
|
||||
self.track_changed.fire(track)
|
||||
|
||||
if Settings.get_config("play_settings").get('download_tracks', False):
|
||||
path = Settings.get_cached_file_path(track.store_id + '.mp3')
|
||||
if settings.get('download_tracks', default=False) or settings.get_is_file_cached(track.filename):
|
||||
path = settings.get_cached_file_path(track.filename)
|
||||
|
||||
if path is None:
|
||||
self.logger.debug('Track %s not in cache, downloading...', track.store_id)
|
||||
logger.debug('Track %s not in cache, downloading...', track.store_id)
|
||||
track.get_url(callback=self._download_track)
|
||||
else:
|
||||
self.logger.debug('Track %s in cache, playing', track.store_id)
|
||||
logger.debug('Track %s in cache, playing', track.store_id)
|
||||
self._play_ready(path, None, track)
|
||||
else:
|
||||
self.logger.debug('Starting to stream %s', track.store_id)
|
||||
logger.debug('Starting to stream %s', track.store_id)
|
||||
track.get_url(callback=self._play_ready)
|
||||
|
||||
def _download_track(self, url, error, track):
|
||||
if error:
|
||||
NotificationArea.notify('Failed to request media URL: {}'.format(str(error)))
|
||||
self.logger.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
|
||||
response = urlopen(url)
|
||||
path = Settings.save_file_to_cache(track.store_id + '.mp3', response.read())
|
||||
path = settings.save_file_to_cache(track.filename, response.read())
|
||||
self._play_ready(path, None, track)
|
||||
|
||||
def _play_ready(self, url, error, track):
|
||||
|
@ -378,8 +364,8 @@ class Player(object):
|
|||
"""
|
||||
self._is_loading = False
|
||||
if error:
|
||||
NotificationArea.notify('Failed to request media URL: {}'.format(str(error)))
|
||||
self.logger.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)
|
||||
|
@ -435,7 +421,7 @@ class Player(object):
|
|||
def next(self, force=False):
|
||||
"""
|
||||
Advance to next track in queue.
|
||||
See :meth:`.Queue.next`.
|
||||
See :meth:`._Queue.next`.
|
||||
"""
|
||||
self.queue.next(force)
|
||||
self._play()
|
||||
|
@ -443,7 +429,7 @@ class Player(object):
|
|||
def get_current_track(self):
|
||||
"""
|
||||
Return currently played track.
|
||||
See :meth:`.Queue.get_current_track`.
|
||||
See :meth:`._Queue.get_current_track`.
|
||||
"""
|
||||
return self.queue.get_current_track()
|
||||
|
||||
|
@ -508,3 +494,6 @@ class Player(object):
|
|||
index
|
||||
) == 0
|
||||
self.media_player.set_equalizer(self.equalizer)
|
||||
|
||||
|
||||
player = _Player() # pylint: disable=invalid-name
|
||||
|
|
155
clay/settings.py
155
clay/settings.py
|
@ -1,77 +1,158 @@
|
|||
"""
|
||||
Application settings manager.
|
||||
"""
|
||||
from threading import Lock
|
||||
import os
|
||||
import copy
|
||||
import errno
|
||||
import yaml
|
||||
import appdirs
|
||||
|
||||
|
||||
# Rewrite this so it keeps the settings in memory and writes on exit
|
||||
# It is sort of silly to use so much IO for simple tasks
|
||||
class Settings(object):
|
||||
class _SettingsEditor(dict):
|
||||
"""
|
||||
Thread-safe settings editor context manager.
|
||||
|
||||
For example see :py:meth:`~._Settings.edit`.
|
||||
"""
|
||||
_lock = Lock()
|
||||
|
||||
def __init__(self, original_config, commit_callback):
|
||||
super(_SettingsEditor, self).__init__()
|
||||
_SettingsEditor._lock.acquire()
|
||||
self._commit_callback = commit_callback
|
||||
self.update(copy.deepcopy(original_config))
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, exc_tb):
|
||||
_SettingsEditor._lock.release()
|
||||
if exc_tb is None:
|
||||
self._commit_callback(self)
|
||||
else:
|
||||
# TODO: Handle this
|
||||
pass
|
||||
|
||||
|
||||
class _Settings(object):
|
||||
"""
|
||||
Settings management class.
|
||||
"""
|
||||
@classmethod
|
||||
def get_config_filename(cls):
|
||||
"""
|
||||
Returns full path to config file and will create it if it doesn't
|
||||
already exist.
|
||||
"""
|
||||
filedir = appdirs.user_config_dir('clay', 'Clay')
|
||||
path = os.path.join(filedir, 'config.yaml')
|
||||
def __init__(self):
|
||||
self._config = {}
|
||||
self._cached_files = set()
|
||||
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
self._config_dir = None
|
||||
self._config_file_path = None
|
||||
self._cache_dir = None
|
||||
|
||||
self._ensure_directories()
|
||||
self._load_config()
|
||||
self._load_cache()
|
||||
|
||||
def _ensure_directories(self):
|
||||
"""
|
||||
Create config dir, config file & cache dir if they do not exist yet.
|
||||
"""
|
||||
self._config_dir = appdirs.user_config_dir('clay', 'Clay')
|
||||
self._config_file_path = os.path.join(self._config_dir, 'config.yaml')
|
||||
|
||||
try:
|
||||
os.makedirs(filedir)
|
||||
os.makedirs(self._config_dir)
|
||||
except OSError as error:
|
||||
if error.errno != errno.EEXIST:
|
||||
raise
|
||||
|
||||
with open(path, 'w') as settings:
|
||||
settings.write('{}')
|
||||
self._cache_dir = appdirs.user_cache_dir('clay', 'Clay')
|
||||
try:
|
||||
os.makedirs(self._cache_dir)
|
||||
except OSError as error:
|
||||
if error.errno != errno.EEXIST:
|
||||
raise
|
||||
|
||||
return path
|
||||
if not os.path.exists(self._config_file_path):
|
||||
with open(self._config_file_path, 'w') as settings_file:
|
||||
settings_file.write('{}')
|
||||
|
||||
@classmethod
|
||||
def get_config(cls, section):
|
||||
def _load_config(self):
|
||||
"""
|
||||
Read config dictionary.
|
||||
Read config from file.
|
||||
"""
|
||||
with open(Settings.get_config_filename(), 'r') as settings:
|
||||
return yaml.load(settings.read()).get(section)
|
||||
with open(self._config_file_path, 'r') as settings_file:
|
||||
self._config = yaml.load(settings_file.read())
|
||||
|
||||
@classmethod
|
||||
def set_config(cls, new_config):
|
||||
def _load_cache(self):
|
||||
"""
|
||||
Write config dictionary.
|
||||
Load cached files.
|
||||
"""
|
||||
config = Settings.get_config('play_settings')
|
||||
config.update(new_config)
|
||||
with open(Settings.get_config_filename(), 'w') as settings:
|
||||
settings.write(yaml.dump(config, default_flow_style=False))
|
||||
self._cached_files = set(os.listdir(self._cache_dir))
|
||||
|
||||
@classmethod
|
||||
def get_cached_file_path(cls, filename):
|
||||
def _commit_edits(self, config):
|
||||
"""
|
||||
Write config to file.
|
||||
|
||||
This method is supposed to be called only
|
||||
from :py:meth:`~._SettingsEditor.__exit__`.
|
||||
>>>>>>> master
|
||||
"""
|
||||
self._config.update(config)
|
||||
with open(self._config_file_path, 'w') as settings_file:
|
||||
settings_file.write(yaml.dump(self._config, default_flow_style=False))
|
||||
|
||||
def get(self, key, section="play_settings", default=None):
|
||||
if section not in self._config:
|
||||
return default
|
||||
|
||||
return self._config[section].get(key, default)
|
||||
|
||||
def get_section(self, key, default=None):
|
||||
"""
|
||||
Return config value.
|
||||
"""
|
||||
return self._config.get(key, default)
|
||||
|
||||
def edit(self):
|
||||
"""
|
||||
Return :py:class:`._SettingsEditor` context manager to edit config.
|
||||
|
||||
Settings are saved to file once the returned context manager exists.
|
||||
|
||||
Example usage:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from clay.settings import settings
|
||||
|
||||
with settings.edit() as config:
|
||||
config['foo']['bar'] = 'baz'
|
||||
"""
|
||||
return _SettingsEditor(self._config, self._commit_edits)
|
||||
|
||||
def get_cached_file_path(self, filename):
|
||||
"""
|
||||
Get full path to cached file.
|
||||
"""
|
||||
cache_dir = appdirs.user_cache_dir('clay', 'Clay')
|
||||
path = os.path.join(cache_dir, filename)
|
||||
path = os.path.join(self._cache_dir, filename)
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def save_file_to_cache(cls, filename, content):
|
||||
def get_is_file_cached(self, filename):
|
||||
"""
|
||||
Return ``True`` if *filename* is present in cache.
|
||||
"""
|
||||
return filename in self._cached_files
|
||||
|
||||
def save_file_to_cache(self, filename, content):
|
||||
"""
|
||||
Save content into file in cache.
|
||||
"""
|
||||
cache_dir = appdirs.user_cache_dir('clay', 'Clay')
|
||||
path = os.path.join(cache_dir, filename)
|
||||
path = os.path.join(self._cache_dir, filename)
|
||||
with open(path, 'wb') as cachefile:
|
||||
cachefile.write(content)
|
||||
self._cached_files.add(filename)
|
||||
return path
|
||||
|
||||
|
||||
settings = _Settings() # pylint: disable=invalid-name
|
||||
|
|
158
clay/songlist.py
158
clay/songlist.py
|
@ -12,10 +12,11 @@ except ImportError:
|
|||
# Python 2.3
|
||||
from string import letters as ascii_letters
|
||||
import urwid
|
||||
from clay.notifications import NotificationArea
|
||||
from clay.player import Player
|
||||
from clay.gp import GP
|
||||
from clay.notifications import notification_area
|
||||
from clay.player import player
|
||||
from clay.gp import gp
|
||||
from clay.clipboard import copy
|
||||
from clay.settings import settings
|
||||
|
||||
|
||||
class SongListItem(urwid.Pile):
|
||||
|
@ -35,6 +36,19 @@ class SongListItem(urwid.Pile):
|
|||
STATE_PLAYING = 2
|
||||
STATE_PAUSED = 3
|
||||
|
||||
LINE1_ATTRS = {
|
||||
STATE_IDLE: ('line1', 'line1_focus'),
|
||||
STATE_LOADING: ('line1_active', 'line1_active_focus'),
|
||||
STATE_PLAYING: ('line1_active', 'line1_active_focus'),
|
||||
STATE_PAUSED: ('line1_active', 'line1_active_focus')
|
||||
}
|
||||
LINE2_ATTRS = {
|
||||
STATE_IDLE: ('line2', 'line2_focus'),
|
||||
STATE_LOADING: ('line2', 'line2_focus'),
|
||||
STATE_PLAYING: ('line2', 'line2_focus'),
|
||||
STATE_PAUSED: ('line2', 'line2_focus')
|
||||
}
|
||||
|
||||
STATE_ICONS = {
|
||||
0: ' ',
|
||||
1: u'\u2505',
|
||||
|
@ -46,8 +60,14 @@ class SongListItem(urwid.Pile):
|
|||
self.track = track
|
||||
self.index = 0
|
||||
self.state = SongListItem.STATE_IDLE
|
||||
self.line1 = urwid.SelectableIcon('', cursor_position=1000)
|
||||
self.line1.set_layout('left', 'clip', None)
|
||||
self.line1_left = urwid.SelectableIcon('', cursor_position=1000)
|
||||
self.line1_left.set_layout('left', 'clip', None)
|
||||
self.line1_right = urwid.Text('x')
|
||||
self.line1 = urwid.Columns([
|
||||
self.line1_left,
|
||||
('pack', self.line1_right),
|
||||
('pack', urwid.Text(' '))
|
||||
])
|
||||
self.line2 = urwid.Text('', wrap='clip')
|
||||
|
||||
self.line1_wrap = urwid.AttrWrap(self.line1, 'line1')
|
||||
|
@ -90,13 +110,7 @@ class SongListItem(urwid.Pile):
|
|||
"""
|
||||
Update text of this item from the attached track.
|
||||
"""
|
||||
if self.state == SongListItem.STATE_IDLE:
|
||||
title_attr = 'line1_focus' if self.is_focused else 'line1'
|
||||
else:
|
||||
title_attr = 'line1_active_focus' if self.is_focused else 'line1_active'
|
||||
artist_attr = 'line2_focus' if self.is_focused else 'line2'
|
||||
|
||||
self.line1.set_text(
|
||||
self.line1_left.set_text(
|
||||
u'{index:3d} {icon} {title} [{minutes:02d}:{seconds:02d}]'.format(
|
||||
index=self.index + 1,
|
||||
icon=self.get_state_icon(self.state),
|
||||
|
@ -105,11 +119,15 @@ class SongListItem(urwid.Pile):
|
|||
seconds=(self.track.duration // 1000) % 60
|
||||
)
|
||||
)
|
||||
if settings.get_is_file_cached(self.track.filename):
|
||||
self.line1_right.set_text(u' \u25bc Cached')
|
||||
else:
|
||||
self.line1_right.set_text(u'')
|
||||
self.line2.set_text(
|
||||
u' {} \u2015 {}'.format(self.track.artist, self.track.album_name)
|
||||
)
|
||||
self.line1_wrap.set_attr(title_attr)
|
||||
self.line2_wrap.set_attr(artist_attr)
|
||||
self.line1_wrap.set_attr(SongListItem.LINE1_ATTRS[self.state][self.is_focused])
|
||||
self.line2_wrap.set_attr(SongListItem.LINE2_ATTRS[self.state][self.is_focused])
|
||||
|
||||
@property
|
||||
def full_title(self):
|
||||
|
@ -198,70 +216,42 @@ class SongListBoxPopup(urwid.LineBox):
|
|||
'panel_divider'
|
||||
)
|
||||
]
|
||||
options.append(urwid.AttrWrap(
|
||||
urwid.Divider(u'\u2500'),
|
||||
'panel_divider',
|
||||
'panel_divider_focus'
|
||||
))
|
||||
if not GP.get().get_track_by_id(songitem.track.id):
|
||||
options.append(urwid.AttrWrap(
|
||||
urwid.Button('Add to my library', on_press=self.add_to_my_library),
|
||||
'panel',
|
||||
'panel_focus'
|
||||
))
|
||||
options.append(self._create_divider())
|
||||
if not gp.get_track_by_id(songitem.track.id):
|
||||
options.append(self._create_button('Add to library', self.add_to_my_library))
|
||||
else:
|
||||
options.append(urwid.AttrWrap(
|
||||
urwid.Button('Remove from my library', on_press=self.remove_from_my_library),
|
||||
'panel',
|
||||
'panel_focus'
|
||||
))
|
||||
options.append(urwid.AttrWrap(
|
||||
urwid.Divider(u'\u2500'),
|
||||
'panel_divider',
|
||||
'panel_divider_focus'
|
||||
))
|
||||
options.append(urwid.AttrWrap(
|
||||
urwid.Button('Create station', on_press=self.create_station),
|
||||
'panel',
|
||||
'panel_focus'
|
||||
))
|
||||
options.append(urwid.AttrWrap(
|
||||
urwid.Divider(u'\u2500'),
|
||||
'panel_divider',
|
||||
'panel_divider_focus'
|
||||
))
|
||||
if self.songitem.track in Player.get().get_queue_tracks():
|
||||
options.append(urwid.AttrWrap(
|
||||
urwid.Button('Remove from queue', on_press=self.remove_from_queue),
|
||||
'panel',
|
||||
'panel_focus'
|
||||
))
|
||||
options.append(self._create_button('Remove from library', self.remove_from_my_library))
|
||||
options.append(self._create_divider())
|
||||
options.append(self._create_button('Create station', self.create_station))
|
||||
options.append(self._create_divider())
|
||||
if self.songitem.track in player.get_queue_tracks():
|
||||
options.append(self._create_button('Remove from queue', self.remove_from_queue))
|
||||
else:
|
||||
options.append(urwid.AttrWrap(
|
||||
urwid.Button('Append to queue', on_press=self.append_to_queue),
|
||||
'panel',
|
||||
'panel_focus'
|
||||
))
|
||||
options.append(urwid.AttrWrap(
|
||||
urwid.Divider(u'\u2500'),
|
||||
'panel_divider',
|
||||
'panel_divider_focus'
|
||||
))
|
||||
options.append(self._create_button('Append to queue', self.append_to_queue))
|
||||
if self.songitem.track.cached_url is not None:
|
||||
options.append(urwid.AttrWrap(
|
||||
urwid.Button('Copy URL to clipboard', on_press=self.copy_url),
|
||||
'panel',
|
||||
'panel_focus'
|
||||
))
|
||||
options.append(urwid.AttrWrap(
|
||||
urwid.Button('Close', on_press=self.close),
|
||||
'panel',
|
||||
'panel_focus'
|
||||
))
|
||||
options.append(self._create_button('Copy URL to clipboard', self.copy_url))
|
||||
options.append(self._create_button('Close', self.close))
|
||||
super(SongListBoxPopup, self).__init__(
|
||||
urwid.Pile(options)
|
||||
)
|
||||
|
||||
def _create_divider(self):
|
||||
"""
|
||||
Return a divider widget.
|
||||
"""
|
||||
return urwid.AttrWrap(
|
||||
urwid.Divider(u'\u2500'),
|
||||
'panel_divider',
|
||||
'panel_divider_focus'
|
||||
)
|
||||
|
||||
def _create_button(self, title, on_press):
|
||||
return urwid.AttrWrap(
|
||||
urwid.Button(title, on_press=on_press),
|
||||
'panel',
|
||||
'panel_focus'
|
||||
)
|
||||
|
||||
def add_to_my_library(self, _):
|
||||
"""
|
||||
Add related track to my library.
|
||||
|
@ -271,11 +261,11 @@ class SongListBoxPopup(urwid.LineBox):
|
|||
Show notification with song addition result.
|
||||
"""
|
||||
if error or not result:
|
||||
NotificationArea.notify('Error while adding track to my library: {}'.format(
|
||||
notification_area.notify('Error while adding track to my library: {}'.format(
|
||||
str(error) if error else 'reason is unknown :('
|
||||
))
|
||||
else:
|
||||
NotificationArea.notify('Track added to library!')
|
||||
notification_area.notify('Track added to library!')
|
||||
self.songitem.track.add_to_my_library_async(callback=on_add_to_my_library)
|
||||
self.close()
|
||||
|
||||
|
@ -288,11 +278,11 @@ class SongListBoxPopup(urwid.LineBox):
|
|||
Show notification with song removal result.
|
||||
"""
|
||||
if error or not result:
|
||||
NotificationArea.notify('Error while removing track from my library: {}'.format(
|
||||
notification_area.notify('Error while removing track from my library: {}'.format(
|
||||
str(error) if error else 'reason is unknown :('
|
||||
))
|
||||
else:
|
||||
NotificationArea.notify('Track removed from library!')
|
||||
notification_area.notify('Track removed from library!')
|
||||
self.songitem.track.remove_from_my_library_async(callback=on_remove_from_my_library)
|
||||
self.close()
|
||||
|
||||
|
@ -300,21 +290,21 @@ class SongListBoxPopup(urwid.LineBox):
|
|||
"""
|
||||
Appends related track to queue.
|
||||
"""
|
||||
Player.get().append_to_queue(self.songitem.track)
|
||||
player.append_to_queue(self.songitem.track)
|
||||
self.close()
|
||||
|
||||
def remove_from_queue(self, _):
|
||||
"""
|
||||
Removes related track from queue.
|
||||
"""
|
||||
Player.get().remove_from_queue(self.songitem.track)
|
||||
player.remove_from_queue(self.songitem.track)
|
||||
self.close()
|
||||
|
||||
def create_station(self, _):
|
||||
"""
|
||||
Create a station from this track.
|
||||
"""
|
||||
Player.get().create_station_from_track(self.songitem.track)
|
||||
player.create_station_from_track(self.songitem.track)
|
||||
self.close()
|
||||
|
||||
def copy_url(self, _):
|
||||
|
@ -344,7 +334,6 @@ class SongListBox(urwid.Frame):
|
|||
self.tracks = []
|
||||
self.walker = urwid.SimpleFocusListWalker([])
|
||||
|
||||
player = Player.get()
|
||||
player.track_changed += self.track_changed
|
||||
player.media_state_changed += self.media_state_changed
|
||||
|
||||
|
@ -432,7 +421,7 @@ class SongListBox(urwid.Frame):
|
|||
"""
|
||||
Convert list of track data items into list of :class:`.SongListItem` instances.
|
||||
"""
|
||||
current_track = Player.get().get_current_track()
|
||||
current_track = player.get_current_track()
|
||||
items = []
|
||||
current_index = None
|
||||
for index, track in enumerate(tracks):
|
||||
|
@ -465,7 +454,6 @@ class SongListBox(urwid.Frame):
|
|||
Toggles track playback state or loads entire playlist
|
||||
that contains current track into player queue.
|
||||
"""
|
||||
player = Player.get()
|
||||
if songitem.is_currently_played:
|
||||
player.play_pause()
|
||||
else:
|
||||
|
@ -477,7 +465,7 @@ class SongListBox(urwid.Frame):
|
|||
Called when specific item emits *append-requested* item.
|
||||
Appends track to player queue.
|
||||
"""
|
||||
Player.get().append_to_queue(songitem.track)
|
||||
player.append_to_queue(songitem.track)
|
||||
|
||||
@staticmethod
|
||||
def item_unappend_requested(songitem):
|
||||
|
@ -485,7 +473,7 @@ class SongListBox(urwid.Frame):
|
|||
Called when specific item emits *remove-requested* item.
|
||||
Removes track from player queue.
|
||||
"""
|
||||
Player.get().remove_from_queue(songitem.track)
|
||||
player.remove_from_queue(songitem.track)
|
||||
|
||||
@staticmethod
|
||||
def item_station_requested(songitem):
|
||||
|
@ -493,7 +481,7 @@ class SongListBox(urwid.Frame):
|
|||
Called when specific item emits *station-requested* item.
|
||||
Requests new station creation.
|
||||
"""
|
||||
Player.get().create_station_from_track(songitem.track)
|
||||
player.create_station_from_track(songitem.track)
|
||||
|
||||
def context_menu_requested(self, songitem):
|
||||
"""
|
||||
|
@ -540,7 +528,7 @@ class SongListBox(urwid.Frame):
|
|||
Called when player media state changes.
|
||||
Updates corresponding song item state (if found in this song list).
|
||||
"""
|
||||
current_track = Player.get().get_current_track()
|
||||
current_track = player.get_current_track()
|
||||
if current_track is None:
|
||||
return
|
||||
|
||||
|
|
3
tox.ini
3
tox.ini
|
@ -12,5 +12,4 @@ deps =
|
|||
gmusicapi
|
||||
pylint
|
||||
commands =
|
||||
pylint clay
|
||||
|
||||
make check
|
||||
|
|
Loading…
Add table
Reference in a new issue