Merge branch 'websocket'
This commit is contained in:
@ -4,13 +4,15 @@ from __future__ import unicode_literals
|
||||
import logging, os, json
|
||||
import tornado.web
|
||||
import tornado.websocket
|
||||
import handlers
|
||||
|
||||
from mopidy import config, ext
|
||||
from frontend import IrisFrontend, make_iris_factory
|
||||
from http import HttpHandler
|
||||
from websocket import WebsocketHandler
|
||||
from frontend import IrisFrontend
|
||||
from handlers import WebsocketHandler, HttpHandler
|
||||
from core import IrisCore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
__version__ = '2.12.1'
|
||||
__version__ = '2.13.0'
|
||||
|
||||
##
|
||||
# Core extension class
|
||||
@ -40,8 +42,35 @@ class Extension( ext.Extension ):
|
||||
# Add web extension
|
||||
registry.add('http:app', {
|
||||
'name': self.ext_name,
|
||||
'factory': make_iris_factory(
|
||||
registry['http:app'],
|
||||
registry['http:static']
|
||||
)
|
||||
'factory': iris_factory
|
||||
})
|
||||
|
||||
# create our core instance
|
||||
mem.iris = IrisCore()
|
||||
mem.iris.version = self.version
|
||||
|
||||
# Add our frontend
|
||||
registry.add('frontend', IrisFrontend)
|
||||
|
||||
|
||||
def iris_factory(config, core):
|
||||
|
||||
path = os.path.join( os.path.dirname(__file__), 'static')
|
||||
|
||||
return [
|
||||
(r"/images/(.*)", tornado.web.StaticFileHandler, {
|
||||
'path': config['local-images']['image_dir']
|
||||
}),
|
||||
(r'/http/([^/]*)', handlers.HttpHandler, {
|
||||
'core': core,
|
||||
'config': config
|
||||
}),
|
||||
(r'/ws/?', handlers.WebsocketHandler, {
|
||||
'core': core,
|
||||
'config': config
|
||||
}),
|
||||
(r'/(.*)', tornado.web.StaticFileHandler, {
|
||||
'path': path,
|
||||
'default_filename': 'index.html'
|
||||
}),
|
||||
]
|
||||
430
mopidy_iris/core.py
Executable file
430
mopidy_iris/core.py
Executable file
@ -0,0 +1,430 @@
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import random, string, logging, json, pykka, pylast, urllib, urllib2, os, sys, mopidy_iris, subprocess
|
||||
import tornado.web
|
||||
import tornado.websocket
|
||||
import tornado.ioloop
|
||||
from mopidy import config, ext
|
||||
from mopidy.core import CoreListener
|
||||
from pkg_resources import parse_version
|
||||
from tornado.escape import json_encode, json_decode
|
||||
from spotipy import Spotify
|
||||
|
||||
# import logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class IrisCore(object):
|
||||
|
||||
version = 0
|
||||
is_root = ( os.geteuid() == 0 )
|
||||
spotify_token = False
|
||||
queue_metadata = {}
|
||||
connections = {}
|
||||
radio = {
|
||||
"enabled": 0,
|
||||
"seed_artists": [],
|
||||
"seed_genres": [],
|
||||
"seed_tracks": []
|
||||
}
|
||||
|
||||
|
||||
##
|
||||
# Generate a random string
|
||||
#
|
||||
# Used for connection_ids where none is provided by client
|
||||
# @return string
|
||||
##
|
||||
def generateGuid(self, length):
|
||||
return ''.join(random.choice(string.lowercase) for i in range(length))
|
||||
|
||||
|
||||
##
|
||||
# Digest a protocol header into it's id/name parts
|
||||
#
|
||||
# @return dict
|
||||
##
|
||||
def digest_protocol(self, protocol):
|
||||
|
||||
# if we're a string, split into list
|
||||
# this handles the different ways we get this passed (select_subprotocols gives string, headers.get gives list)
|
||||
if isinstance(protocol, basestring):
|
||||
|
||||
# make sure we strip any spaces (IE gives "element,element", proper browsers give "element, element")
|
||||
protocol = [i.strip() for i in protocol.split(',')]
|
||||
|
||||
# if we've been given a valid array
|
||||
try:
|
||||
clientid = protocol[0]
|
||||
connection_id = protocol[1]
|
||||
username = protocol[2]
|
||||
generated = False
|
||||
|
||||
# invalid, so just create a default connection, and auto-generate an ID
|
||||
except:
|
||||
clientid = self.generateGuid(12)
|
||||
connection_id = self.generateGuid(12)
|
||||
username = 'Anonymous'
|
||||
generated = True
|
||||
|
||||
# construct our protocol object, and return
|
||||
return {"clientid": clientid, "connection_id": connection_id, "username": username, "generated": generated}
|
||||
|
||||
|
||||
def send_message(self, to, data):
|
||||
self.connections[to]['connection'].write_message( json_encode(data) )
|
||||
|
||||
|
||||
def broadcast(self, data):
|
||||
for connection in self.connections.itervalues():
|
||||
connection['connection'].write_message( json_encode(data) )
|
||||
return {
|
||||
'status': 1,
|
||||
'message': 'Broadcast to '+str(len(self.connections))+' connections'
|
||||
}
|
||||
|
||||
##
|
||||
# Connections
|
||||
#
|
||||
# Contains all our connections and client details. This requires updates
|
||||
# when new clients connect, and old ones disconnect. These events are broadcast
|
||||
# to all current connections
|
||||
##
|
||||
|
||||
def get_connections(self, data):
|
||||
connections = []
|
||||
for connection in self.connections.itervalues():
|
||||
connections.append(connection['client'])
|
||||
|
||||
return {
|
||||
'status': 1,
|
||||
'connections': connections
|
||||
}
|
||||
|
||||
def add_connection(self, connection_id, connection, client):
|
||||
new_connection = {
|
||||
'client': client,
|
||||
'connection': connection
|
||||
}
|
||||
self.connections[connection_id] = new_connection
|
||||
|
||||
self.send_message(
|
||||
connection_id, {
|
||||
'type': 'connected',
|
||||
'connection_id': connection_id,
|
||||
'username': client['username']
|
||||
}
|
||||
)
|
||||
|
||||
self.broadcast({
|
||||
'type': 'connection_added',
|
||||
'connection': client
|
||||
})
|
||||
|
||||
def remove_connection(self, connection_id):
|
||||
if connection_id in self.connections:
|
||||
try:
|
||||
client = self.connections[connection_id]['client']
|
||||
del self.connections[connection_id]
|
||||
self.broadcast({
|
||||
'type': 'connection_removed',
|
||||
'connection': client
|
||||
})
|
||||
except:
|
||||
logger.error('Failed to close connection to '+ connection_id)
|
||||
|
||||
def set_username(self, data):
|
||||
connection_id = data['connection_id']
|
||||
if connection_id in self.connections:
|
||||
self.connections[connection_id]['client']['username'] = data['username']
|
||||
self.broadcast({
|
||||
'type': 'connection_updated',
|
||||
'connection': self.connections[connection_id]['client']
|
||||
})
|
||||
return {
|
||||
'status': 1,
|
||||
'username': data['username']
|
||||
}
|
||||
|
||||
else:
|
||||
error = 'Connection "'+data['connection_id']+'" not found'
|
||||
logger.error(error)
|
||||
return {
|
||||
'status': 0,
|
||||
'message': error
|
||||
}
|
||||
|
||||
def deliver_message(self, data):
|
||||
to = data['to']
|
||||
if to in self.connections:
|
||||
|
||||
self.send_message(to, data['message'])
|
||||
|
||||
return {
|
||||
'status': 1
|
||||
}
|
||||
|
||||
else:
|
||||
error = 'Connection "'+data['connection_id']+'" not found'
|
||||
logger.error(error)
|
||||
return {
|
||||
'status': 0,
|
||||
'message': error
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
##
|
||||
# System controls
|
||||
#
|
||||
# Faciitates upgrades and configuration fetching
|
||||
##
|
||||
|
||||
def get_config(self, data):
|
||||
config = {
|
||||
"spotify_username": self.config['spotify']['username'],
|
||||
"country": self.config['iris']['country'],
|
||||
"locale": self.config['iris']['locale']
|
||||
}
|
||||
return {
|
||||
'config': config
|
||||
}
|
||||
|
||||
def get_version(self, data):
|
||||
|
||||
url = 'https://pypi.python.org/pypi/Mopidy-Iris/json'
|
||||
req = urllib2.Request(url)
|
||||
|
||||
try:
|
||||
response = urllib2.urlopen(req, timeout=30).read()
|
||||
response = json.loads(response)
|
||||
latest_version = response['info']['version']
|
||||
|
||||
# compare our versions, and convert result to boolean
|
||||
upgrade_available = cmp( parse_version( latest_version ), parse_version( self.version ) )
|
||||
upgrade_available = ( upgrade_available == 1 )
|
||||
|
||||
except urllib2.HTTPError as e:
|
||||
latest_version = '0.0.0'
|
||||
upgrade_available = False
|
||||
|
||||
return {
|
||||
'status': 1,
|
||||
'version': {
|
||||
'current': self.version,
|
||||
'latest': latest_version,
|
||||
'is_root': self.is_root,
|
||||
'upgrade_available': upgrade_available
|
||||
}
|
||||
}
|
||||
|
||||
def perform_upgrade( self ):
|
||||
try:
|
||||
subprocess.check_call(["pip", "install", "--upgrade", "Mopidy-Iris"])
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
def restart( self ):
|
||||
os.execl(sys.executable, *([sys.executable]+sys.argv))
|
||||
|
||||
|
||||
##
|
||||
# Spotify Radio
|
||||
#
|
||||
# Accepts seed URIs and creates radio-like experience. When our tracklist is nearly
|
||||
# empty, we fetch more recommendations. This can result in duplicates. We keep the
|
||||
# recommendations limit low to avoid timeouts and slow UI
|
||||
##
|
||||
|
||||
def get_radio(self, data):
|
||||
return {
|
||||
'status': 1,
|
||||
'radio': self.radio
|
||||
}
|
||||
|
||||
def start_radio(self, data):
|
||||
self.radio = data
|
||||
self.radio['enabled'] = 1;
|
||||
|
||||
uris = self.load_more_tracks()
|
||||
|
||||
# no uris means we can't play radio
|
||||
if not uris:
|
||||
return {
|
||||
'status': 0,
|
||||
'message': 'No recommendations found'
|
||||
}
|
||||
|
||||
# if we got recommendations
|
||||
else:
|
||||
self.core.tracklist.clear()
|
||||
self.core.tracklist.set_consume( True )
|
||||
self.core.tracklist.add( uris = uris )
|
||||
self.core.playback.play()
|
||||
|
||||
self.broadcast({
|
||||
'type': 'radio_started',
|
||||
'radio': self.radio
|
||||
})
|
||||
|
||||
return self.get_radio({})
|
||||
|
||||
|
||||
def stop_radio(self, data):
|
||||
self.radio = {
|
||||
"enabled": 0,
|
||||
"seed_artists": [],
|
||||
"seed_genres": [],
|
||||
"seed_tracks": []
|
||||
}
|
||||
|
||||
self.core.playback.stop()
|
||||
|
||||
self.broadcast({
|
||||
'type': 'radio_stopped',
|
||||
'radio': self.radio
|
||||
})
|
||||
|
||||
return {
|
||||
'status': 1
|
||||
}
|
||||
|
||||
|
||||
def load_more_tracks( self ):
|
||||
|
||||
# this is crude, but it means we don't need to handle expired tokens
|
||||
# TODO: address this when it's clear what Jodal and the team want to do with Pyspotify
|
||||
self.refresh_spotify_token({})
|
||||
|
||||
try:
|
||||
token = self.spotify_token
|
||||
token = token['access_token']
|
||||
except:
|
||||
logger.error('IrisFrontend: access_token missing or invalid')
|
||||
self.broadcast({
|
||||
'error': 'Could not get radio tracks: access_token missing or invalid'
|
||||
})
|
||||
|
||||
try:
|
||||
spotify = Spotify( auth = token )
|
||||
response = spotify.recommendations(seed_artists = self.radio['seed_artists'], seed_genres = self.radio['seed_genres'], seed_tracks = self.radio['seed_tracks'], limit = 5)
|
||||
|
||||
uris = []
|
||||
for track in response['tracks']:
|
||||
uris.append( track['uri'] )
|
||||
|
||||
return uris
|
||||
except:
|
||||
logger.error('IrisFrontend: Failed to fetch Spotify recommendations')
|
||||
return False
|
||||
|
||||
|
||||
def check_for_radio_update( self ):
|
||||
tracklistLength = self.core.tracklist.length.get()
|
||||
if( tracklistLength <= 5 and self.radio['enabled'] == 1 ):
|
||||
|
||||
uris = self.load_more_tracks()
|
||||
|
||||
if not uris:
|
||||
self.broadcast({
|
||||
'error': 'Could not fetch tracklist length',
|
||||
'source': 'check_for_radio_update'
|
||||
})
|
||||
logger.warning('IrisFrontend: Could not fetch tracklist length')
|
||||
|
||||
else:
|
||||
self.core.tracklist.add( uris = uris )
|
||||
|
||||
|
||||
|
||||
##
|
||||
# Additional queue metadata
|
||||
#
|
||||
# This maps tltracks with extra info for display in Iris, including
|
||||
# added_by and from_uri.
|
||||
##
|
||||
|
||||
def get_queue_metadata(self, data):
|
||||
return {
|
||||
'status': 1,
|
||||
'queue_metadata': self.queue_metadata
|
||||
}
|
||||
|
||||
def add_queue_metadata(self, data):
|
||||
|
||||
for tlid in data['tlids']:
|
||||
item = {
|
||||
'tlid': tlid,
|
||||
'added_from': data['added_from'],
|
||||
'added_by': data['added_by']
|
||||
}
|
||||
self.queue_metadata['tlid_'+str(tlid)] = item
|
||||
|
||||
self.broadcast({
|
||||
'type': 'queue_metadata_changed',
|
||||
'queue_metadata': self.queue_metadata
|
||||
})
|
||||
|
||||
return {
|
||||
'status': 1
|
||||
}
|
||||
|
||||
def clean_queue_metadata( self ):
|
||||
cleaned_queue_metadata = {}
|
||||
|
||||
for tltrack in self.core.tracklist.get_tl_tracks().get():
|
||||
|
||||
# if we have metadata for this track, push it through to cleaned dictionary
|
||||
if 'tlid_'+str(tltrack.tlid) in self.queue_metadata:
|
||||
cleaned_queue_metadata['tlid_'+str(tltrack.tlid)] = self.queue_metadata['tlid_'+str(tltrack.tlid)]
|
||||
|
||||
self.queue_metadata = cleaned_queue_metadata
|
||||
|
||||
self.broadcast({
|
||||
'type': 'queue_metadata_changed',
|
||||
'queue_metadata': self.queue_metadata
|
||||
})
|
||||
|
||||
return {
|
||||
'status': 1
|
||||
}
|
||||
|
||||
|
||||
##
|
||||
# Spotify authentication
|
||||
#
|
||||
# Uses the Client Credentials Flow, so is invisible to the user. We need this token for
|
||||
# any backend spotify requests (we don't tap in to Mopidy-Spotify, yet). Also used for
|
||||
# passing token to frontend for javascript requests without use of the Authorization Code Flow.
|
||||
##
|
||||
|
||||
def get_spotify_token(self, data):
|
||||
return {
|
||||
'spotify_token': self.spotify_token
|
||||
}
|
||||
|
||||
def refresh_spotify_token(self, data):
|
||||
|
||||
url = 'https://accounts.spotify.com/api/token'
|
||||
authorization = 'YTg3ZmI0ZGJlZDMwNDc1YjhjZWMzODUyM2RmZjUzZTI6ZDdjODlkMDc1M2VmNDA2OGJiYTE2NzhjNmNmMjZlZDY='
|
||||
|
||||
headers = {'Authorization' : 'Basic ' + authorization}
|
||||
data = {'grant_type': 'client_credentials'}
|
||||
data_encoded = urllib.urlencode( data )
|
||||
req = urllib2.Request(url, data_encoded, headers)
|
||||
|
||||
try:
|
||||
response = urllib2.urlopen(req, timeout=30).read()
|
||||
response_dict = json.loads(response)
|
||||
self.spotify_token = response_dict
|
||||
|
||||
self.broadcast({
|
||||
'type': 'spotify_token_changed',
|
||||
'spotify_token': self.spotify_token
|
||||
})
|
||||
|
||||
return self.get_spotify_token({})
|
||||
except urllib2.HTTPError as e:
|
||||
return e
|
||||
@ -1,322 +1,27 @@
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import logging, json, pykka, pylast, urllib, urllib2, os, sys, mopidy_iris, subprocess
|
||||
import tornado.web
|
||||
import tornado.websocket
|
||||
import tornado.ioloop
|
||||
from mopidy import config, ext
|
||||
from mopidy.core import CoreListener
|
||||
from pkg_resources import parse_version
|
||||
from spotipy import Spotify
|
||||
|
||||
from websocket import WebsocketHandler
|
||||
from http import HttpHandler
|
||||
import mem
|
||||
import pykka
|
||||
import logging
|
||||
|
||||
# import logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
###
|
||||
# Create our factory
|
||||
#
|
||||
# This hooks all our components into the Mopidy registry. Called from __init__.py
|
||||
##
|
||||
def make_iris_factory(apps, statics):
|
||||
def iris_factory(config, core):
|
||||
|
||||
path = os.path.join( os.path.dirname(__file__), 'static')
|
||||
frontend = IrisFrontend(config, core)
|
||||
|
||||
return [
|
||||
(r"/images/(.*)", tornado.web.StaticFileHandler, {
|
||||
"path": config['local-images']['image_dir']
|
||||
}),
|
||||
(r'/http/([^/]*)', HttpHandler, {
|
||||
"frontend": frontend
|
||||
}),
|
||||
(r'/ws/?', WebsocketHandler, {
|
||||
"frontend": frontend
|
||||
}),
|
||||
(r'/(.*)', tornado.web.StaticFileHandler, {
|
||||
"path": path,
|
||||
"default_filename": "index.html"
|
||||
}),
|
||||
]
|
||||
return iris_factory
|
||||
|
||||
###
|
||||
# Spotmop supporting frontend
|
||||
#
|
||||
# This provides a wrapping thread for the Pusher websocket, as well as the radio infrastructure
|
||||
##
|
||||
class IrisFrontend(pykka.ThreadingActor, CoreListener):
|
||||
|
||||
def __init__(self, config, core):
|
||||
super(IrisFrontend, self).__init__()
|
||||
self.config = config
|
||||
self.core = core
|
||||
self.version = mopidy_iris.__version__
|
||||
self.is_root = ( os.geteuid() == 0 )
|
||||
self.spotify_token = False
|
||||
self.queue_metadata = {}
|
||||
self.connections = {}
|
||||
self.radio = {
|
||||
"enabled": 0,
|
||||
"seed_artists": [],
|
||||
"seed_genres": [],
|
||||
"seed_tracks": []
|
||||
}
|
||||
mem.iris.core = core
|
||||
mem.iris.config = config
|
||||
|
||||
def on_start(self):
|
||||
logger.info('Starting Iris '+self.version)
|
||||
logger.info('Starting Iris '+mem.iris.version)
|
||||
|
||||
|
||||
##
|
||||
# Get a new spotify authentication token for server-side use
|
||||
#
|
||||
# Uses the Client Credentials Flow, so is invisible to the user. We need this token for
|
||||
# any backend spotify requests (we don't tap in to Mopidy-Spotify, yet). Also used for
|
||||
# passing token to frontend for javascript requests without use of the Authorization Code Flow.
|
||||
##
|
||||
def refresh_spotify_token( self ):
|
||||
|
||||
url = 'https://accounts.spotify.com/api/token'
|
||||
authorization = 'YTg3ZmI0ZGJlZDMwNDc1YjhjZWMzODUyM2RmZjUzZTI6ZDdjODlkMDc1M2VmNDA2OGJiYTE2NzhjNmNmMjZlZDY='
|
||||
|
||||
headers = {'Authorization' : 'Basic ' + authorization}
|
||||
data = {'grant_type': 'client_credentials'}
|
||||
data_encoded = urllib.urlencode( data )
|
||||
req = urllib2.Request(url, data_encoded, headers)
|
||||
|
||||
try:
|
||||
response = urllib2.urlopen(req, timeout=30).read()
|
||||
response_dict = json.loads(response)
|
||||
self.spotify_token = response_dict
|
||||
return response_dict
|
||||
except urllib2.HTTPError as e:
|
||||
return e
|
||||
|
||||
|
||||
##
|
||||
# Listen for core events, and update our frontend as required
|
||||
##
|
||||
def track_playback_ended( self, tl_track, time_position ):
|
||||
self.check_for_radio_update()
|
||||
mem.iris.check_for_radio_update()
|
||||
|
||||
def tracklist_changed( self ):
|
||||
self.clean_queue_metadata()
|
||||
|
||||
|
||||
##
|
||||
# See if we need to perform updates to our radio
|
||||
#
|
||||
# We see if we've got one or two tracks left, if so, go get some more
|
||||
##
|
||||
def check_for_radio_update( self ):
|
||||
try:
|
||||
tracklistLength = self.core.tracklist.length.get()
|
||||
if( tracklistLength <= 5 and self.radio['enabled'] == 1 ):
|
||||
self.load_more_tracks()
|
||||
|
||||
except RuntimeError:
|
||||
self.websocket.broadcast('error', {'source': 'check_for_radio_update', 'message': 'Could not fetch tracklist length'})
|
||||
logger.warning('IrisFrontend: Could not fetch tracklist length')
|
||||
pass
|
||||
|
||||
|
||||
##
|
||||
# Load some more radio tracks
|
||||
#
|
||||
# We need to build a Spotify authentication token first, and then fetch recommendations
|
||||
##
|
||||
def load_more_tracks( self ):
|
||||
|
||||
# this is crude, but it means we don't need to handle expired tokens
|
||||
# TODO: address this when it's clear what Jodal and the team want to do with Pyspotify
|
||||
self.refresh_spotify_token()
|
||||
|
||||
try:
|
||||
token = self.spotify_token
|
||||
token = token['access_token']
|
||||
except:
|
||||
logger.error('IrisFrontend: access_token missing or invalid')
|
||||
self.websocket.broadcast('error', {'source': 'load_more_tracks', 'message': 'access_token missing or invalid'})
|
||||
|
||||
try:
|
||||
spotify = Spotify( auth = token )
|
||||
response = spotify.recommendations(seed_artists = self.radio['seed_artists'], seed_genres = self.radio['seed_genres'], seed_tracks = self.radio['seed_tracks'], limit = 5)
|
||||
|
||||
uris = []
|
||||
for track in response['tracks']:
|
||||
uris.append( track['uri'] )
|
||||
|
||||
self.core.tracklist.add( uris = uris )
|
||||
except:
|
||||
self.websocket.broadcast('error', {'source': 'load_more_tracks', 'message': 'Failed to fetch Spotify recommendations'})
|
||||
logger.error('IrisFrontend: Failed to fetch Spotify recommendations')
|
||||
|
||||
|
||||
##
|
||||
# Start radio
|
||||
#
|
||||
# Take the provided radio details, and start a new radio process
|
||||
##
|
||||
def start_radio( self, new_state ):
|
||||
|
||||
# set our new radio state
|
||||
self.radio = new_state
|
||||
self.radio['enabled'] = 1;
|
||||
|
||||
# clear all tracks
|
||||
self.core.tracklist.clear()
|
||||
|
||||
# explicitly set consume, to ensure we don't end up with a huge tracklist (and it's how a radio should 'feel')
|
||||
self.core.tracklist.set_consume( True )
|
||||
|
||||
# load me some tracks, and start playing!
|
||||
self.load_more_tracks()
|
||||
self.core.playback.play()
|
||||
|
||||
# notify clients
|
||||
self.websocket.broadcast('radio', { 'radio': self.radio })
|
||||
|
||||
# return new radio state to initial call
|
||||
return self.radio
|
||||
|
||||
##
|
||||
# Stop radio
|
||||
##
|
||||
def stop_radio( self ):
|
||||
|
||||
# reset radio
|
||||
self.radio = {
|
||||
"enabled": 0,
|
||||
"seed_artists": [],
|
||||
"seed_genres": [],
|
||||
"seed_tracks": []
|
||||
}
|
||||
|
||||
# stop track playback
|
||||
self.core.playback.stop()
|
||||
|
||||
# notify clients
|
||||
self.websocket.broadcast( 'radio', { 'radio': self.radio })
|
||||
|
||||
# return new radio state to initial call
|
||||
return self.radio
|
||||
|
||||
|
||||
# get our spotify token
|
||||
def get_spotify_token( self ):
|
||||
return self.spotify_token
|
||||
|
||||
|
||||
##
|
||||
# Queue metadata
|
||||
##
|
||||
|
||||
def get_queue_metadata( self ):
|
||||
return self.queue_metadata
|
||||
|
||||
def add_queue_metadata( self, tlids, added_from, added_by ):
|
||||
|
||||
for tlid in tlids:
|
||||
item = {
|
||||
'tlid': tlid,
|
||||
'added_from': added_from,
|
||||
'added_by': added_by
|
||||
}
|
||||
self.queue_metadata['tlid_'+str(tlid)] = item
|
||||
|
||||
# broadcast to all clients
|
||||
self.websocket.broadcast('queue_metadata', {'queue_metadata': self.queue_metadata})
|
||||
|
||||
return self.queue_metadata
|
||||
|
||||
# fetch our tracklist, and remove any metadata for tlids that don't exist anymore
|
||||
def clean_queue_metadata( self ):
|
||||
|
||||
cleaned_queue_metadata = {}
|
||||
|
||||
# get and loop all tltracks
|
||||
for tltrack in self.core.tracklist.get_tl_tracks().get():
|
||||
|
||||
# if we have metadata for this track, push it through to cleaned dictionary
|
||||
if 'tlid_'+str(tltrack.tlid) in self.queue_metadata:
|
||||
cleaned_queue_metadata['tlid_'+str(tltrack.tlid)] = self.queue_metadata['tlid_'+str(tltrack.tlid)]
|
||||
|
||||
# update our cleaned store
|
||||
self.queue_metadata = cleaned_queue_metadata
|
||||
|
||||
# broadcast to all clients
|
||||
self.websocket.broadcast('queue_metadata', {'queue_metadata': self.queue_metadata})
|
||||
|
||||
|
||||
##
|
||||
# System configuration
|
||||
#
|
||||
# This enables Iris to respect system config
|
||||
##
|
||||
def get_config( self ):
|
||||
all_config = self.config
|
||||
config = {
|
||||
"spotify_username": all_config['spotify']['username'],
|
||||
"country": all_config['iris']['country'],
|
||||
"locale": all_config['iris']['locale']
|
||||
}
|
||||
return config
|
||||
|
||||
|
||||
##
|
||||
# Get Spotmop version, and check for updates
|
||||
#
|
||||
# We compare our version with the latest available on PyPi
|
||||
##
|
||||
def get_version( self ):
|
||||
|
||||
url = 'https://pypi.python.org/pypi/Mopidy-Iris/json'
|
||||
req = urllib2.Request(url)
|
||||
|
||||
try:
|
||||
response = urllib2.urlopen(req, timeout=30).read()
|
||||
response = json.loads(response)
|
||||
latest_version = response['info']['version']
|
||||
|
||||
# compare our versions, and convert result to boolean
|
||||
upgrade_available = cmp( parse_version( latest_version ), parse_version( self.version ) )
|
||||
upgrade_available = ( upgrade_available == 1 )
|
||||
|
||||
except urllib2.HTTPError as e:
|
||||
latest_version = '0.0.0'
|
||||
upgrade_available = False
|
||||
|
||||
# prepare our response
|
||||
data = {
|
||||
'current': self.version,
|
||||
'latest': latest_version,
|
||||
'is_root': self.is_root,
|
||||
'upgrade_available': upgrade_available
|
||||
}
|
||||
return data
|
||||
|
||||
|
||||
##
|
||||
# Upgrade Spotmop module
|
||||
#
|
||||
# Upgrade myself to the latest version available on PyPi
|
||||
##
|
||||
def perform_upgrade( self ):
|
||||
try:
|
||||
subprocess.check_call(["pip", "install", "--upgrade", "Mopidy-Iris"])
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
##
|
||||
# Restart Mopidy
|
||||
#
|
||||
# This is untested and may require installation of an upstart script to properly restart
|
||||
##
|
||||
def restart( self ):
|
||||
os.execl(sys.executable, *([sys.executable]+sys.argv))
|
||||
mem.iris.clean_queue_metadata()
|
||||
|
||||
133
mopidy_iris/handlers.py
Executable file
133
mopidy_iris/handlers.py
Executable file
@ -0,0 +1,133 @@
|
||||
|
||||
from __future__ import unicode_literals
|
||||
import tornado.ioloop, tornado.web, tornado.websocket, tornado.template
|
||||
import random, string, logging, uuid, subprocess, pykka, ast
|
||||
from datetime import datetime
|
||||
from tornado.escape import json_encode, json_decode
|
||||
import logging, json, urllib, urllib2
|
||||
import tornado.web
|
||||
from spotipy import Spotify
|
||||
|
||||
import mem
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebsocketHandler(tornado.websocket.WebSocketHandler):
|
||||
|
||||
# initiate (not the actual object __init__, but run shortly after)
|
||||
def initialize(self, core, config):
|
||||
self.core = core
|
||||
self.config = config
|
||||
|
||||
def check_origin(self, origin):
|
||||
return True
|
||||
|
||||
def select_subprotocol(self, subprotocols):
|
||||
|
||||
# select one of our subprotocol elements and return it. This confirms the connection has been accepted.
|
||||
protocols = mem.iris.digest_protocol( subprotocols )
|
||||
|
||||
# if we've auto-generated some ids, the provided subprotocols was a string, so just return it right back
|
||||
# this allows a connection to be completed
|
||||
if protocols['generated']:
|
||||
return subprotocols[0]
|
||||
|
||||
# otherwise, just return one of the supplied subprotocols
|
||||
else:
|
||||
return protocols['clientid']
|
||||
|
||||
def open(self):
|
||||
|
||||
# decode our connection protocol value (which is a payload of id/name from javascript)
|
||||
protocolElements = mem.iris.digest_protocol(self.request.headers.get('Sec-Websocket-Protocol', []))
|
||||
|
||||
connection_id = protocolElements['connection_id']
|
||||
clientid = protocolElements['clientid']
|
||||
self.connection_id = connection_id
|
||||
username = protocolElements['username']
|
||||
created = datetime.strftime(datetime.now(), '%Y-%m-%d %H:%M:%S')
|
||||
|
||||
# construct our client object, and add to our list of connections
|
||||
client = {
|
||||
'clientid': clientid,
|
||||
'connection_id': connection_id,
|
||||
'username': username,
|
||||
'ip': self.request.remote_ip,
|
||||
'created': created
|
||||
}
|
||||
|
||||
# add to connections
|
||||
mem.iris.add_connection(connection_id, self, client)
|
||||
|
||||
|
||||
def on_message(self, message):
|
||||
|
||||
message = json_decode(message)
|
||||
|
||||
if 'data' in message:
|
||||
data = message['data']
|
||||
else:
|
||||
data = {}
|
||||
|
||||
data['connection_id'] = self.connection_id
|
||||
|
||||
if 'request_id' in message:
|
||||
request_id = message['request_id']
|
||||
else:
|
||||
request_id = False
|
||||
|
||||
# call the method, as specified in payload
|
||||
if 'method' in message:
|
||||
|
||||
# make sure the method exists
|
||||
if hasattr(mem.iris, message['method']):
|
||||
|
||||
# make the call, and return it's response
|
||||
response = getattr(mem.iris, message['method'])(data)
|
||||
if response:
|
||||
response['request_id'] = request_id
|
||||
mem.iris.send_message(self.connection_id, response)
|
||||
else:
|
||||
response = {
|
||||
'error': 'Method "'+message['method']+'" does not exist',
|
||||
'request_id': request_id
|
||||
}
|
||||
mem.iris.send_message(self.connection_id, response)
|
||||
else:
|
||||
response = {
|
||||
'error': 'Method key missing from request',
|
||||
'request_id': request_id
|
||||
}
|
||||
mem.iris.send_message(self.connection_id, response)
|
||||
|
||||
|
||||
def on_close(self):
|
||||
mem.iris.remove_connection(self.connection_id)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class HttpHandler(tornado.web.RequestHandler):
|
||||
|
||||
def set_default_headers(self):
|
||||
self.set_header("Access-Control-Allow-Origin", "*")
|
||||
|
||||
def initialize(self, core, config):
|
||||
self.core = core
|
||||
self.config = config
|
||||
|
||||
def get(self, slug=None):
|
||||
|
||||
# make sure the method exists
|
||||
if hasattr(mem.iris, slug):
|
||||
|
||||
# make the call, and return it's response
|
||||
self.write(getattr(mem.iris, slug)({}))
|
||||
else:
|
||||
self.write({
|
||||
'error': 'Method "'+slug+'" does not exist'
|
||||
})
|
||||
|
||||
@ -1,53 +0,0 @@
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import logging, json, urllib, urllib2
|
||||
import tornado.web
|
||||
from spotipy import Spotify
|
||||
|
||||
# import logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class HttpHandler(tornado.web.RequestHandler):
|
||||
|
||||
def set_default_headers(self):
|
||||
self.set_header("Access-Control-Allow-Origin", "*")
|
||||
|
||||
def initialize(self, frontend):
|
||||
self.frontend = frontend
|
||||
|
||||
def get(self, slug=None):
|
||||
|
||||
if( slug == 'refresh_spotify_token' ):
|
||||
self.write( self.refresh_spotify_token() )
|
||||
return
|
||||
|
||||
else:
|
||||
self.write('Invalid request')
|
||||
return
|
||||
|
||||
|
||||
##
|
||||
# Get a new spotify authentication token
|
||||
#
|
||||
# Uses the Client Credentials Flow, so is invisible to the user. We need this token for
|
||||
# any backend spotify requests (we don't tap in to Mopidy-Spotify, yet). Also used for
|
||||
# passing token to frontend for javascript requests without use of the Authorization Code Flow.
|
||||
##
|
||||
def refresh_spotify_token( self ):
|
||||
|
||||
url = 'https://accounts.spotify.com/api/token'
|
||||
authorization = 'YTg3ZmI0ZGJlZDMwNDc1YjhjZWMzODUyM2RmZjUzZTI6ZDdjODlkMDc1M2VmNDA2OGJiYTE2NzhjNmNmMjZlZDY='
|
||||
|
||||
headers = {'Authorization' : 'Basic ' + authorization}
|
||||
data = {'grant_type': 'client_credentials'}
|
||||
data_encoded = urllib.urlencode( data )
|
||||
req = urllib2.Request(url, data_encoded, headers)
|
||||
|
||||
try:
|
||||
response = urllib2.urlopen(req, timeout=30).read()
|
||||
response = json.loads(response)
|
||||
return response
|
||||
except urllib2.HTTPError as e:
|
||||
return e
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
queuemanager = None
|
||||
localfiles = None
|
||||
|
||||
iris = None
|
||||
|
||||
|
||||
@ -1,359 +0,0 @@
|
||||
|
||||
import tornado.ioloop, tornado.web, tornado.websocket, tornado.template
|
||||
import random, string, logging, uuid, subprocess, pykka
|
||||
from datetime import datetime
|
||||
from tornado.escape import json_encode, json_decode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# generate random string
|
||||
def generateGuid(length):
|
||||
return ''.join(random.choice(string.lowercase) for i in range(length))
|
||||
|
||||
# digest a protocol header into it's id/name parts
|
||||
def digest_protocol( protocol ):
|
||||
|
||||
# if we're a string, split into list
|
||||
# this handles the different ways we get this passed (select_subprotocols gives string, headers.get gives list)
|
||||
if isinstance(protocol, basestring):
|
||||
|
||||
# make sure we strip any spaces (IE gives "element,element", proper browsers give "element, element")
|
||||
protocol = [i.strip() for i in protocol.split(',')]
|
||||
|
||||
# if we've been given a valid array
|
||||
try:
|
||||
clientid = protocol[0]
|
||||
connectionid = protocol[1]
|
||||
username = protocol[2]
|
||||
generated = False
|
||||
|
||||
# invalid, so just create a default connection, and auto-generate an ID
|
||||
except:
|
||||
clientid = generateGuid(12)
|
||||
connectionid = generateGuid(12)
|
||||
username = 'Anonymous'
|
||||
generated = True
|
||||
|
||||
# construct our protocol object, and return
|
||||
return {"clientid": clientid, "connectionid": connectionid, "username": username, "generated": generated}
|
||||
|
||||
|
||||
##
|
||||
# Websocket server
|
||||
#
|
||||
# This is the actual websocket thread that accepts, digests and emits messages.
|
||||
# TODO: Figure out how to merge this into the main Mopidy websocket to avoid needing two websocket servers
|
||||
##
|
||||
class WebsocketHandler(tornado.websocket.WebSocketHandler):
|
||||
|
||||
|
||||
# initiate (not the actual object __init__, but run shortly after)
|
||||
def initialize(self, frontend):
|
||||
|
||||
# add this websocket instance to our Frontend
|
||||
frontend.websocket = self
|
||||
self.frontend = frontend
|
||||
|
||||
|
||||
# when a new connection is opened
|
||||
def open(self):
|
||||
|
||||
# decode our connection protocol value (which is a payload of id/name from javascript)
|
||||
protocolElements = digest_protocol(self.request.headers.get('Sec-Websocket-Protocol', []))
|
||||
|
||||
connectionid = protocolElements['connectionid']
|
||||
clientid = protocolElements['clientid']
|
||||
self.connectionid = connectionid
|
||||
username = protocolElements['username']
|
||||
created = datetime.strftime(datetime.now(), '%Y-%m-%d %H:%M:%S')
|
||||
|
||||
# construct our client object, and add to our list of connections
|
||||
client = {
|
||||
'clientid': clientid,
|
||||
'connectionid': connectionid,
|
||||
'username': username,
|
||||
'ip': self.request.remote_ip,
|
||||
'created': created
|
||||
}
|
||||
self.frontend.connections[connectionid] = {
|
||||
'client': client,
|
||||
'connection': self
|
||||
}
|
||||
|
||||
logger.info( 'Pusher connection established: '+ connectionid +' ('+ clientid +'/'+ username +')' )
|
||||
|
||||
# broadcast to all connections that a new user has connected
|
||||
self.broadcast( 'new_connection', client )
|
||||
|
||||
|
||||
def check_origin(self, origin):
|
||||
return True
|
||||
|
||||
|
||||
def select_subprotocol(self, subprotocols):
|
||||
# select one of our subprotocol elements and return it. This confirms the connection has been accepted.
|
||||
protocols = digest_protocol( subprotocols )
|
||||
|
||||
# if we've auto-generated some ids, the provided subprotocols was a string, so just return it right back
|
||||
# this allows a connection to be completed
|
||||
if protocols['generated']:
|
||||
return subprotocols[0]
|
||||
|
||||
# otherwise, just return one of the supplied subprotocols
|
||||
else:
|
||||
return protocols['clientid']
|
||||
|
||||
|
||||
# server received a message
|
||||
def on_message(self, message):
|
||||
messageJson = json_decode(message)
|
||||
|
||||
# construct the origin client info
|
||||
messageJson['origin'] = {
|
||||
'connectionid' : self.connectionid,
|
||||
'clientid': self.frontend.connections[self.connectionid]['client']['clientid'],
|
||||
'ip': self.request.remote_ip,
|
||||
'username': self.frontend.connections[self.connectionid]['client']['username']
|
||||
}
|
||||
|
||||
logger.debug('Pusher message received: '+message)
|
||||
|
||||
# broadcast message to other connections (except for self)
|
||||
if messageJson['action'] == 'broadcast':
|
||||
|
||||
# respond to request with status update
|
||||
self.send_message(
|
||||
self.connectionid,
|
||||
'response',
|
||||
messageJson['request_id'],
|
||||
{ 'status': 'Ok' }
|
||||
)
|
||||
|
||||
for connection in self.frontend.connections.itervalues():
|
||||
if connection['client']['connectionid'] != self.connectionid:
|
||||
connection['connection'].write_message(messageJson)
|
||||
|
||||
# send authroization details
|
||||
elif messageJson['action'] == 'send_authorization':
|
||||
|
||||
# make sure we actually have a connection matching the provided connectionid
|
||||
if messageJson['recipient_connectionid'] in self.frontend.connections:
|
||||
|
||||
# send payload to recipient
|
||||
authorization_message = {
|
||||
'type': 'broadcast',
|
||||
'action': 'received_authorization',
|
||||
'authorization': messageJson['authorization'],
|
||||
'me': messageJson['me'],
|
||||
'origin': messageJson['origin']
|
||||
}
|
||||
self.frontend.connections[messageJson['recipient_connectionid']]['connection'].write_message(authorization_message)
|
||||
|
||||
# respond to request with status update
|
||||
self.send_message(
|
||||
self.connectionid,
|
||||
'response',
|
||||
messageJson['request_id'],
|
||||
{ 'status': 'Ok' }
|
||||
)
|
||||
else:
|
||||
# respond to request with status update
|
||||
self.send_message(
|
||||
self.connectionid,
|
||||
'response',
|
||||
messageJson['request_id'],
|
||||
{ 'error': 'Could not send to that connection, does not exist' }
|
||||
)
|
||||
|
||||
# fetch our pusher connections
|
||||
elif messageJson['action'] == 'get_config':
|
||||
self.send_message(
|
||||
self.connectionid,
|
||||
'response',
|
||||
messageJson['request_id'],
|
||||
{ 'config': self.frontend.get_config() }
|
||||
)
|
||||
|
||||
# fetch our pusher connections
|
||||
elif messageJson['action'] == 'get_connections':
|
||||
|
||||
connectionsDetailsList = []
|
||||
for connection in self.frontend.connections.itervalues():
|
||||
connectionsDetailsList.append(connection['client'])
|
||||
|
||||
self.send_message(
|
||||
self.connectionid,
|
||||
'response',
|
||||
messageJson['request_id'],
|
||||
{ 'connections': connectionsDetailsList }
|
||||
)
|
||||
|
||||
# add some queue metadata
|
||||
elif messageJson['action'] == 'add_queue_metadata':
|
||||
queue_metadata = self.frontend.add_queue_metadata(
|
||||
messageJson['tlids'],
|
||||
messageJson['added_from'],
|
||||
self.frontend.connections[self.connectionid]['client']['username']
|
||||
)
|
||||
|
||||
self.send_message(
|
||||
self.connectionid,
|
||||
'response',
|
||||
messageJson['request_id'],
|
||||
{ 'queue_metadata': queue_metadata }
|
||||
)
|
||||
|
||||
# get our queue metadata (added_by, from, etc)
|
||||
elif messageJson['action'] == 'get_queue_metadata':
|
||||
|
||||
connectionsDetailsList = []
|
||||
for connection in self.frontend.connections.itervalues():
|
||||
connectionsDetailsList.append(connection['client'])
|
||||
|
||||
self.send_message(
|
||||
self.connectionid,
|
||||
'response',
|
||||
messageJson['request_id'],
|
||||
{ 'queue_metadata': self.frontend.get_queue_metadata() }
|
||||
)
|
||||
|
||||
# change connection's client username
|
||||
elif messageJson['action'] == 'set_username':
|
||||
|
||||
# username is the only value we allow clients to change
|
||||
self.frontend.connections[messageJson['origin']['connectionid']]['client']['username'] = messageJson['username']
|
||||
|
||||
# respond to request
|
||||
self.send_message(
|
||||
self.connectionid,
|
||||
'response',
|
||||
messageJson['request_id'],
|
||||
{ 'username': messageJson['username'] }
|
||||
)
|
||||
|
||||
# notify all clients of this change
|
||||
self.broadcast( 'connection_updated', { 'connection': self.frontend.connections[messageJson['origin']['connectionid']]['client'] })
|
||||
|
||||
# start radio
|
||||
elif messageJson['action'] == 'start_radio':
|
||||
|
||||
# pull out just the radio data (we don't want all the request_id guff)
|
||||
radio = {
|
||||
'enabled': 1,
|
||||
'seed_artists': messageJson['seed_artists'],
|
||||
'seed_genres': messageJson['seed_genres'],
|
||||
'seed_tracks': messageJson['seed_tracks']
|
||||
}
|
||||
radio = self.frontend.start_radio( radio )
|
||||
self.send_message(
|
||||
self.connectionid,
|
||||
'response',
|
||||
messageJson['request_id'],
|
||||
{ 'radio': radio }
|
||||
)
|
||||
|
||||
# stop radio
|
||||
elif messageJson['action'] == 'stop_radio':
|
||||
radio = self.frontend.stop_radio()
|
||||
self.send_message(
|
||||
self.connectionid,
|
||||
'response',
|
||||
messageJson['request_id'],
|
||||
{ 'radio': self.frontend.radio }
|
||||
)
|
||||
|
||||
# fetch our current radio state
|
||||
elif messageJson['action'] == 'get_radio':
|
||||
self.send_message(
|
||||
self.connectionid,
|
||||
'response',
|
||||
messageJson['request_id'],
|
||||
{ 'radio': self.frontend.radio }
|
||||
)
|
||||
|
||||
# get system version and check for upgrade
|
||||
elif messageJson['action'] == 'get_version':
|
||||
version = self.frontend.get_version()
|
||||
self.send_message(
|
||||
self.connectionid,
|
||||
'response',
|
||||
messageJson['request_id'],
|
||||
{ 'version': version }
|
||||
)
|
||||
|
||||
# perform upgrade
|
||||
elif messageJson['action'] == 'upgrade':
|
||||
version = self.frontend.get_version()
|
||||
upgrade_successful = self.frontend.perform_upgrade()
|
||||
self.send_message(
|
||||
self.connectionid,
|
||||
'response',
|
||||
messageJson['request_id'],
|
||||
{ 'upgrade_successful': upgrade_successful, 'version': version }
|
||||
)
|
||||
|
||||
# restart mopidy
|
||||
elif messageJson['action'] == 'restart':
|
||||
self.frontend.restart()
|
||||
|
||||
# not an action we recognise!
|
||||
else:
|
||||
self.send_message(
|
||||
self.connectionid,
|
||||
'response',
|
||||
messageJson['request_id'],
|
||||
{ 'error': 'Unhandled action' }
|
||||
)
|
||||
|
||||
logger.debug( 'Pusher: Unhandled message received from '+ self.connectionid )
|
||||
|
||||
|
||||
# connection closed
|
||||
def on_close(self):
|
||||
if self.connectionid in self.frontend.connections:
|
||||
|
||||
clientRemoved = self.frontend.connections[self.connectionid]['client']
|
||||
logger.debug( 'Spotmop Pusher connection to '+ self.connectionid +' closed' )
|
||||
|
||||
# now actually remove it
|
||||
try:
|
||||
del self.frontend.connections[self.connectionid]
|
||||
except:
|
||||
logger.info( 'Failed to close connection to '+ self.connectionid )
|
||||
|
||||
self.broadcast( 'client_disconnected', clientRemoved )
|
||||
|
||||
##
|
||||
# Send a message to an individual connection
|
||||
#
|
||||
# @param recipient_connection_ids = array
|
||||
# @param action = string (action method of this message)
|
||||
# @param request_id = string (used for callbacks)
|
||||
# @param data = array (any data required to include in our message)
|
||||
##
|
||||
def send_message( self, recipient_connection_id, action, request_id, data ):
|
||||
message = {
|
||||
'action': action,
|
||||
'request_id': request_id,
|
||||
'data': data
|
||||
}
|
||||
self.frontend.connections[recipient_connection_id]['connection'].write_message( json_encode(message) )
|
||||
|
||||
##
|
||||
# Broadcast a message to all recipients
|
||||
#
|
||||
# @param action = string
|
||||
# @param data = array (the body of our message to send)
|
||||
##
|
||||
def broadcast( self, type, data ):
|
||||
for connection in self.frontend.connections.itervalues():
|
||||
message = {
|
||||
'action': 'broadcast',
|
||||
'type': type,
|
||||
'data': data
|
||||
}
|
||||
connection['connection'].write_message( json_encode(message) )
|
||||
|
||||
|
||||
|
||||
|
||||
10
src/assets/icons/connection.svg
Executable file
10
src/assets/icons/connection.svg
Executable file
@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="16px" height="16px" viewBox="0 0 16 16" enable-background="new 0 0 16 16" xml:space="preserve">
|
||||
<line fill="none" stroke="#000000" stroke-miterlimit="10" x1="5.25" y1="4.19" x2="5.25" y2="13.217"/>
|
||||
<polyline points="2.716,6.29 5.25,3.065 7.784,6.29 "/>
|
||||
<line fill="none" stroke="#000000" stroke-miterlimit="10" x1="10.75" y1="12.092" x2="10.75" y2="3.065"/>
|
||||
<polyline points="13.284,9.992 10.75,13.217 8.216,9.992 "/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 803 B |
13
src/assets/icons/server.svg
Executable file
13
src/assets/icons/server.svg
Executable file
@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="16px" height="16px" viewBox="0 0 16 16" enable-background="new 0 0 16 16" xml:space="preserve">
|
||||
<ellipse fill="none" stroke="#000000" stroke-miterlimit="10" cx="8" cy="4.306" rx="4.438" ry="1.292"/>
|
||||
<path fill="none" stroke="#000000" stroke-miterlimit="10" d="M12.438,6.915c0,0.714-1.986,1.292-4.438,1.292
|
||||
S3.563,7.629,3.563,6.915"/>
|
||||
<path fill="none" stroke="#000000" stroke-miterlimit="10" d="M12.438,9.523c0,0.714-1.986,1.292-4.438,1.292
|
||||
s-4.438-0.578-4.438-1.292"/>
|
||||
<path fill="none" stroke="#000000" stroke-miterlimit="10" d="M3.563,4.306v7.825c0,0.713,1.986,1.292,4.438,1.292
|
||||
s4.438-0.579,4.438-1.292V4.306"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1007 B |
30
src/assets/icons/websocket.svg
Executable file
30
src/assets/icons/websocket.svg
Executable file
@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="16px" height="16px" viewBox="0 0 16 16" enable-background="new 0 0 16 16" xml:space="preserve">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.376,13.476c-0.57-0.571-1.143-1.142-1.714-1.717
|
||||
c-0.055-0.055-0.114-0.063-0.185-0.062c-0.467,0-0.933,0-1.401,0c-1.578,0-3.156,0-4.735,0c-0.068,0-0.131,0.005-0.188-0.054
|
||||
c-0.466-0.471-0.935-0.938-1.404-1.404c-0.051-0.051-0.047-0.078,0.001-0.124c0.168-0.164,0.336-0.326,0.496-0.496
|
||||
c0.074-0.082,0.113-0.063,0.18,0.006c0.354,0.36,0.716,0.717,1.07,1.078c0.064,0.062,0.125,0.092,0.217,0.092
|
||||
c0.774-0.005,1.549-0.003,2.323-0.004c0.044,0,0.088,0.017,0.132-0.023c-0.035-0.038-0.07-0.077-0.109-0.115
|
||||
C9.285,9.879,8.512,9.101,7.732,8.33c-0.081-0.082-0.079-0.125,0.003-0.2C7.889,7.988,8.039,7.84,8.178,7.684
|
||||
c0.083-0.09,0.125-0.088,0.212-0.001c0.77,0.779,1.547,1.553,2.323,2.327c0.034,0.035,0.061,0.087,0.133,0.097
|
||||
c0-0.048,0-0.091,0-0.134c0-0.769-0.002-1.538,0.002-2.308c0-0.096-0.03-0.162-0.099-0.228c-0.35-0.345-0.692-0.698-1.047-1.04
|
||||
C9.613,6.31,9.595,6.258,9.699,6.165c0.172-0.157,0.328-0.332,0.491-0.498c0.07-0.036,0.093,0.03,0.128,0.064
|
||||
c0.444,0.442,0.887,0.889,1.333,1.329c0.072,0.072,0.103,0.141,0.103,0.243c-0.004,1.114-0.002,2.229-0.006,3.344
|
||||
c0,0.109,0.02,0.152,0.144,0.149c0.52-0.008,1.041-0.007,1.562,0c0.117,0.002,0.144-0.034,0.144-0.146
|
||||
c-0.003-1.378-0.002-2.756-0.002-4.133c0-0.085-0.022-0.145-0.083-0.205C12.88,5.683,12.25,5.054,11.623,4.42
|
||||
c-0.075-0.076-0.113-0.083-0.192-0.001c-0.365,0.375-0.738,0.745-1.112,1.116c-0.035,0.033-0.058,0.1-0.128,0.063
|
||||
c-0.009-0.012-0.016-0.025-0.026-0.035C9.152,4.551,8.138,3.54,7.127,2.526C7.067,2.467,7.005,2.459,6.93,2.459
|
||||
C4.904,2.46,2.877,2.46,0.851,2.461c-0.044,0-0.096-0.021-0.142,0.024C0.743,2.522,0.775,2.559,0.81,2.592
|
||||
c0.537,0.538,1.076,1.074,1.61,1.614c0.072,0.072,0.142,0.1,0.242,0.098c1.169-0.002,2.337-0.001,3.507-0.001
|
||||
c0.074,0,0.138,0.004,0.197,0.065c0.393,0.399,0.788,0.795,1.188,1.188c0.071,0.068,0.051,0.101-0.007,0.158
|
||||
C6.95,6.309,6.354,6.904,5.76,7.502c-0.063,0.064-0.096,0.065-0.159,0c-0.39-0.396-0.783-0.789-1.178-1.178
|
||||
C4.365,6.268,4.345,6.211,4.346,6.132c0.003-0.268-0.002-0.536,0.003-0.804c0.002-0.096-0.024-0.123-0.121-0.122
|
||||
C3.697,5.21,3.166,5.212,2.635,5.205C2.518,5.204,2.503,5.246,2.503,5.348c0.004,0.512,0.004,1.023,0,1.535
|
||||
c0,0.095,0.027,0.162,0.095,0.229c0.991,0.986,1.977,1.979,2.969,2.962c0.096,0.094,0.082,0.137-0.005,0.223
|
||||
c-0.366,0.356-0.722,0.723-1.088,1.08c-0.078,0.076-0.079,0.114,0,0.191c0.63,0.623,1.257,1.252,1.881,1.881
|
||||
c0.067,0.067,0.134,0.093,0.229,0.093c2.893-0.001,5.789-0.001,8.682-0.002c0.047,0,0.095,0.017,0.146-0.018
|
||||
C15.397,13.505,15.389,13.489,15.376,13.476z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
2
src/js/bootstrap.js
vendored
2
src/js/bootstrap.js
vendored
@ -37,7 +37,7 @@ var initialState = {
|
||||
connected: false,
|
||||
port: 6681,
|
||||
username: 'Anonymous',
|
||||
connections: [],
|
||||
connections: {},
|
||||
version: {
|
||||
current: '0.0.0'
|
||||
}
|
||||
|
||||
@ -15,27 +15,34 @@ class SendAuthorizationModal extends React.Component{
|
||||
super(props)
|
||||
}
|
||||
|
||||
handleClick(e, connectionid){
|
||||
handleClick(e, connection_id){
|
||||
e.preventDefault()
|
||||
this.props.pusherActions.sendAuthorization( connectionid, this.props.authorization, this.props.me )
|
||||
this.props.pusherActions.sendAuthorization( connection_id, this.props.authorization, this.props.me )
|
||||
this.props.uiActions.closeModal()
|
||||
return false;
|
||||
}
|
||||
|
||||
render(){
|
||||
var connections = []
|
||||
for (var connection_id in this.props.connections){
|
||||
if (this.props.connections.hasOwnProperty(connection_id)) {
|
||||
connections.push(this.props.connections[connection_id])
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4 className="no-bottom-padding">Share Spotify authentication</h4>
|
||||
<h3 className="grey-text">Send your authentication tokens to another client. When the recipient client imports this, their Iris will have full access to your Spotify account ({this.props.me.id}).</h3>
|
||||
<div className="list small pusher-connection-list">
|
||||
{
|
||||
this.props.connections.map( (connection, index) => {
|
||||
connections.map( (connection, index) => {
|
||||
|
||||
// don't list OUR connection
|
||||
if (connection.connectionid == this.props.connectionid) return null
|
||||
if (connection.connection_id == this.props.connection_id) return null
|
||||
|
||||
return (
|
||||
<div className='list-item connection' key={connection.connectionid} onClick={ e => this.handleClick(e, connection.connectionid) }>
|
||||
<div className='list-item connection' key={connection.connection_id} onClick={ e => this.handleClick(e, connection.connection_id) }>
|
||||
{ connection.username }
|
||||
|
||||
<span className="grey-text">({ connection.ip })</span>
|
||||
@ -53,7 +60,7 @@ const mapStateToProps = (state, ownProps) => {
|
||||
return {
|
||||
me: state.spotify.me,
|
||||
authorization: state.spotify.authorization,
|
||||
connectionid: state.pusher.connectionid,
|
||||
connection_id: state.pusher.connection_id,
|
||||
connections: state.pusher.connections
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,20 +28,28 @@ class PusherConnectionList extends React.Component{
|
||||
|
||||
render(){
|
||||
if( !this.props.connected ) return <div className="pusher-connection-list grey-text">Not connected</div>
|
||||
if( typeof(this.props.connections) == 'undefined' || this.props.connections.length <= 0 ) return <div className="pusher-connection-list grey-text">No connections</div>;
|
||||
|
||||
var connections = []
|
||||
for (var connection_id in this.props.connections){
|
||||
if (this.props.connections.hasOwnProperty(connection_id)) {
|
||||
connections.push(this.props.connections[connection_id])
|
||||
}
|
||||
}
|
||||
|
||||
if (connections.length <= 0) return <div className="pusher-connection-list grey-text">No connections</div>;
|
||||
|
||||
return (
|
||||
<div className="pusher-connection-list">
|
||||
{
|
||||
this.props.connections.map( (connection, index) => {
|
||||
connections.map( (connection, index) => {
|
||||
var is_me = false;
|
||||
if( connection.connectionid == this.props.connectionid ) is_me = true;
|
||||
if( connection.connection_id == this.props.connection_id ) is_me = true;
|
||||
return (
|
||||
<div className={ is_me ? 'connection cf me': 'connection cf'} key={connection.connectionid}>
|
||||
<div className={ is_me ? 'connection cf me': 'connection cf'} key={connection.connection_id}>
|
||||
<div className="col w30">{ connection.username } { is_me ? <span>(you)</span> : null }</div>
|
||||
<div className="col w70">
|
||||
{ connection.ip }
|
||||
<span className="grey-text"> ({ connection.connectionid })</span>
|
||||
<span className="grey-text"> ({ connection.connection_id })</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -55,7 +63,7 @@ class PusherConnectionList extends React.Component{
|
||||
const mapStateToProps = (state, ownProps) => {
|
||||
return {
|
||||
connected: state.pusher.connected,
|
||||
connectionid: state.pusher.connectionid,
|
||||
connection_id: state.pusher.connection_id,
|
||||
connections: state.pusher.connections
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,8 +23,7 @@ const localstorageMiddleware = (function(){
|
||||
if( !pusher ) pusher = {};
|
||||
Object.assign(
|
||||
pusher,{
|
||||
username: action.connection.username,
|
||||
connectionid: action.connection.connectionid
|
||||
connection_id: action.connection_id
|
||||
}
|
||||
);
|
||||
localStorage.setItem('pusher', JSON.stringify(pusher));
|
||||
@ -37,9 +36,9 @@ const localstorageMiddleware = (function(){
|
||||
localStorage.setItem('pusher', JSON.stringify(pusher));
|
||||
break;
|
||||
|
||||
case 'PUSHER_USERNAME':
|
||||
case 'PUSHER_USERNAME_CHANGED':
|
||||
var stored_pusher = JSON.parse( localStorage.getItem('pusher') )
|
||||
var pusher = Object.assign({}, stored_pusher, { username: action.data.username })
|
||||
var pusher = Object.assign({}, stored_pusher, { username: action.username })
|
||||
localStorage.setItem('pusher', JSON.stringify(pusher))
|
||||
break;
|
||||
|
||||
|
||||
@ -37,7 +37,7 @@ export function startUpgrade(){
|
||||
|
||||
export function getConnections(){
|
||||
return {
|
||||
type: 'GET_CONNECTIONS'
|
||||
type: 'PUSHER_GET_CONNECTIONS'
|
||||
}
|
||||
}
|
||||
|
||||
@ -50,30 +50,33 @@ export function instruct( data = null ){
|
||||
|
||||
export function sendAuthorization( recipient_connectionid, authorization, me ){
|
||||
return {
|
||||
type: 'PUSHER_SEND_AUTHORIZATION',
|
||||
recipient_connectionid: recipient_connectionid,
|
||||
authorization: authorization,
|
||||
me: me
|
||||
type: 'PUSHER_DELIVER_MESSAGE',
|
||||
to: recipient_connectionid,
|
||||
message: {
|
||||
type: 'spotify_authorization',
|
||||
authorization: authorization,
|
||||
me: me
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function startRadio( uris ){
|
||||
return {
|
||||
type: 'START_RADIO',
|
||||
type: 'PUSHER_START_RADIO',
|
||||
uris: uris
|
||||
}
|
||||
}
|
||||
|
||||
export function stopRadio(){
|
||||
return {
|
||||
type: 'STOP_RADIO'
|
||||
type: 'PUSHER_STOP_RADIO'
|
||||
}
|
||||
}
|
||||
|
||||
export function debug( data = null ){
|
||||
export function debug( message = null ){
|
||||
return {
|
||||
type: 'PUSHER_DEBUG',
|
||||
data: data
|
||||
message: message
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -23,66 +23,43 @@ const PusherMiddleware = (function(){
|
||||
// handle all manner of socket messages
|
||||
const handleMessage = (ws, store, message) => {
|
||||
|
||||
//console.log('handleMessage', message)
|
||||
// response to a request [we] made
|
||||
if (typeof(message.request_id) !== 'undefined' && message.request_id){
|
||||
if (typeof( deferredRequests[ message.request_id ]) !== 'undefined' ){
|
||||
deferredRequests[ message.request_id ].resolve( message )
|
||||
} else {
|
||||
console.error('Pusher: Response with no matching request', message);
|
||||
}
|
||||
|
||||
switch (message.action){
|
||||
case 'response':
|
||||
if (typeof( deferredRequests[ message.request_id ]) !== 'undefined' ){
|
||||
deferredRequests[ message.request_id ].resolve( message )
|
||||
} else {
|
||||
console.error('Pusher: Response with no matching request', message);
|
||||
}
|
||||
break
|
||||
|
||||
case 'broadcast':
|
||||
if (message.type ){
|
||||
var type = message.type.toUpperCase()
|
||||
} else if (message.data.type ){
|
||||
var type = message.data.type.toUpperCase()
|
||||
} else {
|
||||
var type = 'UNRECOGNISED_BROADCAST'
|
||||
}
|
||||
store.dispatch({ type: type, data: message.data })
|
||||
break
|
||||
// general message
|
||||
// this can be client-client, server-client or a broadcast to many clients
|
||||
} else {
|
||||
message.type = 'PUSHER_'+message.type.toUpperCase()
|
||||
store.dispatch(message)
|
||||
}
|
||||
}
|
||||
|
||||
const request = (data) => {
|
||||
const request = (method, data = {}) => {
|
||||
return new Promise( (resolve, reject) => {
|
||||
|
||||
// send the payload
|
||||
data.request_id = helpers.generateGuid()
|
||||
socket.send( JSON.stringify(data) )
|
||||
var request_id = helpers.generateGuid()
|
||||
var message = {
|
||||
method: method,
|
||||
data: data,
|
||||
request_id: request_id
|
||||
}
|
||||
socket.send( JSON.stringify(message) )
|
||||
|
||||
// add query to our deferred responses
|
||||
deferredRequests[ data.request_id ] = {
|
||||
deferredRequests[request_id] = {
|
||||
resolve: resolve,
|
||||
reject: reject
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Middleware
|
||||
*
|
||||
* This behaves like an action interceptor. We listen for specific actions
|
||||
* and handle special functionality. If the action is not in our switch, then
|
||||
* it just proceeds to the next middleware, or default functionality
|
||||
**/
|
||||
return store => next => action => {
|
||||
|
||||
switch(action.type) {
|
||||
|
||||
case 'PUSHER_INSTRUCT':
|
||||
request( action.data )
|
||||
.then(
|
||||
response => {
|
||||
store.dispatch({ type: 'PUSHER_INSTRUCT', data: response.data })
|
||||
}
|
||||
)
|
||||
break
|
||||
|
||||
case 'PUSHER_CONNECT':
|
||||
|
||||
if(socket != null) socket.close();
|
||||
@ -91,7 +68,7 @@ const PusherMiddleware = (function(){
|
||||
var state = store.getState();
|
||||
var connection = {
|
||||
clientid: helpers.generateGuid(),
|
||||
connectionid: helpers.generateGuid(),
|
||||
connection_id: helpers.generateGuid(),
|
||||
username: 'Anonymous'
|
||||
}
|
||||
if( state.pusher.username ) connection.username = state.pusher.username;
|
||||
@ -99,60 +76,68 @@ const PusherMiddleware = (function(){
|
||||
|
||||
socket = new WebSocket(
|
||||
'ws://'+state.mopidy.host+':'+state.mopidy.port+'/iris/ws',
|
||||
[ connection.clientid, connection.connectionid, connection.username ]
|
||||
[ connection.clientid, connection.connection_id, connection.username ]
|
||||
);
|
||||
|
||||
socket.onopen = () => {
|
||||
store.dispatch({ type: 'PUSHER_CONNECTED', connection: connection });
|
||||
store.dispatch({ type: 'PUSHER_SET_USERNAME', username: connection.username });
|
||||
};
|
||||
|
||||
socket.onmessage = (message) => {
|
||||
var message = JSON.parse(message.data);
|
||||
handleMessage( socket, store, message )
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
store.dispatch({
|
||||
type: 'PUSHER_DISCONNECTED'
|
||||
})
|
||||
|
||||
// attempt to reconnect ever 5 seconds
|
||||
setTimeout(() => {
|
||||
store.dispatch(pusherActions.connect())
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
break;
|
||||
|
||||
case 'PUSHER_CONNECTED':
|
||||
request({ action: 'get_config' })
|
||||
request('get_config')
|
||||
.then(
|
||||
response => {
|
||||
if (response.data.error){
|
||||
console.error(response.data.error)
|
||||
if (response.error){
|
||||
console.error(response.error)
|
||||
return false
|
||||
}
|
||||
|
||||
store.dispatch({ type: 'CONFIG', config: response.data.config })
|
||||
if (response.data.config.spotify_username){
|
||||
store.dispatch(spotifyActions.getUser('spotify:user:'+response.data.config.spotify_username))
|
||||
response.type = 'PUSHER_CONFIG'
|
||||
store.dispatch(response)
|
||||
if (response.config.spotify_username){
|
||||
store.dispatch(spotifyActions.getUser('spotify:user:'+response.config.spotify_username))
|
||||
}
|
||||
var spotify = store.getState().spotify
|
||||
if (!spotify.country || !spotify.locale){
|
||||
store.dispatch({ type: 'SPOTIFY_SET_CONFIG', config: response.data.config })
|
||||
store.dispatch({ type: 'SPOTIFY_SET_CONFIG', config: response.config })
|
||||
}
|
||||
}
|
||||
)
|
||||
request({ action: 'get_version' })
|
||||
request('get_version')
|
||||
.then(
|
||||
response => {
|
||||
if (response.data.error){
|
||||
console.error(response.data.error)
|
||||
if (response.error){
|
||||
console.error(response.error)
|
||||
return false
|
||||
}
|
||||
|
||||
store.dispatch({ type: 'VERSION', data: response.data })
|
||||
response.type = 'PUSHER_VERSION'
|
||||
store.dispatch(response)
|
||||
}
|
||||
)
|
||||
request({ action: 'get_radio' })
|
||||
request('get_radio')
|
||||
.then(
|
||||
response => {
|
||||
if (response.data.error){
|
||||
console.error(response.data.error)
|
||||
if (response.error){
|
||||
console.error(response.error)
|
||||
return false
|
||||
}
|
||||
|
||||
store.dispatch({ type: 'RADIO', data: response.data })
|
||||
response.type = 'PUSHER_RADIO'
|
||||
store.dispatch(response)
|
||||
}
|
||||
)
|
||||
|
||||
@ -161,145 +146,123 @@ const PusherMiddleware = (function(){
|
||||
return next(action);
|
||||
break;
|
||||
|
||||
case 'ERROR':
|
||||
store.dispatch( uiActions.createNotification(action.data.source+': '+action.data.message,'bad') )
|
||||
break;
|
||||
|
||||
case 'PUSHER_GET_QUEUE_METADATA':
|
||||
request({ action: 'get_queue_metadata'})
|
||||
case 'PUSHER_INSTRUCT':
|
||||
request( action )
|
||||
.then(
|
||||
response => {
|
||||
store.dispatch({ type: 'QUEUE_METADATA', data: response.data })
|
||||
store.dispatch({ type: 'PUSHER_INSTRUCT', data: response.data })
|
||||
}
|
||||
)
|
||||
break
|
||||
|
||||
case 'PUSHER_DELIVER_MESSAGE':
|
||||
request('deliver_message', action)
|
||||
.then(
|
||||
response => {
|
||||
store.dispatch( uiActions.createNotification('Message delivered') )
|
||||
}
|
||||
)
|
||||
break
|
||||
|
||||
case 'PUSHER_GET_QUEUE_METADATA':
|
||||
request('get_queue_metadata')
|
||||
.then(
|
||||
response => {
|
||||
response.type = 'QUEUE_METADATA'
|
||||
store.dispatch(response)
|
||||
}
|
||||
)
|
||||
break;
|
||||
|
||||
case 'PUSHER_ADD_QUEUE_METADATA':
|
||||
request({ action: 'add_queue_metadata', tlids: action.tlids, added_from: action.from_uri })
|
||||
request('add_queue_metadata', {
|
||||
tlids: action.tlids,
|
||||
added_from: action.from_uri,
|
||||
added_by: store.getState().pusher.username
|
||||
})
|
||||
break;
|
||||
|
||||
case 'START_UPGRADE':
|
||||
request({ action: 'upgrade' })
|
||||
.then(
|
||||
response => {
|
||||
if (response.data.error){
|
||||
console.error(response.data.error)
|
||||
return false
|
||||
}
|
||||
|
||||
if (response.data.upgrade_successful){
|
||||
store.dispatch( uiActions.createNotification('Upgrade complete') )
|
||||
}else{
|
||||
store.dispatch( uiActions.createNotification('Upgrade failed, please upgrade manually','bad') )
|
||||
}
|
||||
store.dispatch({ type: 'VERSION', data: response.data })
|
||||
case 'PUSHER_START_UPGRADE':
|
||||
request('upgrade')
|
||||
.then(
|
||||
response => {
|
||||
if (response.error){
|
||||
console.error(response.error)
|
||||
return false
|
||||
}
|
||||
)
|
||||
|
||||
if (response.upgrade_successful){
|
||||
store.dispatch( uiActions.createNotification('Upgrade complete') )
|
||||
}else{
|
||||
store.dispatch( uiActions.createNotification('Upgrade failed, please upgrade manually','bad') )
|
||||
}
|
||||
|
||||
response.type = 'PUSHER_VERSION'
|
||||
store.dispatch(response)
|
||||
}
|
||||
)
|
||||
return next(action);
|
||||
break;
|
||||
|
||||
case 'PUSHER_SET_USERNAME':
|
||||
request({ action: 'set_username', username: action.username })
|
||||
.then(
|
||||
response => {
|
||||
if (response.data.error){
|
||||
console.error(response.data.error)
|
||||
return false
|
||||
}
|
||||
|
||||
store.dispatch({ type: 'PUSHER_USERNAME', data: { username: response.data.username }})
|
||||
request('set_username', {
|
||||
username: action.username
|
||||
})
|
||||
.then(
|
||||
response => {
|
||||
if (response.error){
|
||||
console.error(response.error)
|
||||
return false
|
||||
}
|
||||
)
|
||||
response.type = 'PUSHER_USERNAME_CHANGED'
|
||||
store.dispatch(response)
|
||||
}
|
||||
)
|
||||
return next(action);
|
||||
break;
|
||||
|
||||
case 'GET_CONNECTIONS':
|
||||
case 'NEW_CONNECTION':
|
||||
request({ action: 'get_connections' })
|
||||
.then(
|
||||
response => {
|
||||
if (response.data.error){
|
||||
console.error(response.data.error)
|
||||
return false
|
||||
}
|
||||
|
||||
store.dispatch({ type: 'CONNECTIONS', data: response.data })
|
||||
case 'PUSHER_GET_CONNECTIONS':
|
||||
request('get_connections')
|
||||
.then(
|
||||
response => {
|
||||
if (response.error){
|
||||
console.error(response.error)
|
||||
return false
|
||||
}
|
||||
)
|
||||
response.type = 'PUSHER_CONNECTIONS'
|
||||
store.dispatch(response)
|
||||
}
|
||||
)
|
||||
return next(action);
|
||||
break
|
||||
|
||||
case 'PUSHER_DEBUG':
|
||||
request( action.data )
|
||||
.then(
|
||||
response => {
|
||||
if (response.data.error){
|
||||
console.error(response.data.error)
|
||||
return false
|
||||
}
|
||||
|
||||
store.dispatch({ type: 'DEBUG', response: response.data })
|
||||
}
|
||||
)
|
||||
break;
|
||||
|
||||
case 'PUSHER_SEND_AUTHORIZATION':
|
||||
request({
|
||||
action: 'send_authorization',
|
||||
recipient_connectionid: action.recipient_connectionid,
|
||||
authorization: action.authorization,
|
||||
me: action.me
|
||||
})
|
||||
request( action.message.method, action.message.data )
|
||||
.then(
|
||||
response => {
|
||||
if (response.data.error){
|
||||
console.error(response.data.error)
|
||||
return false
|
||||
}
|
||||
|
||||
store.dispatch( uiActions.createNotification('Authorization sent') )
|
||||
response => {
|
||||
store.dispatch({type: 'DEBUG', response: response})
|
||||
}
|
||||
)
|
||||
break;
|
||||
|
||||
case 'PUSHER_SEND_AUTHORIZATION':
|
||||
if( window.confirm('Spotify authorization for user '+action.data.me.id+' received. Do you want to import?') ){
|
||||
case 'PUSHER_SPOTIFY_AUTHORIZATION':
|
||||
if( window.confirm('Spotify authorization for user '+action.me.id+' received. Do you want to import?') ){
|
||||
|
||||
// remove any existing authentication
|
||||
store.dispatch({ type: 'SPOTIFY_AUTHORIZATION_REVOKED' })
|
||||
|
||||
// import our new authentication
|
||||
store.dispatch({ type: 'SPOTIFY_ME_LOADED', data: action.data.me })
|
||||
store.dispatch({ type: 'SPOTIFY_AUTHORIZATION_GRANTED', data: action.data.authorization })
|
||||
store.dispatch({ type: 'SPOTIFY_ME_LOADED', data: action.me })
|
||||
store.dispatch({ type: 'SPOTIFY_AUTHORIZATION_GRANTED', data: action.authorization })
|
||||
}else{
|
||||
console.log('Authorization ignored')
|
||||
}
|
||||
break
|
||||
|
||||
case 'START_RADIO':
|
||||
|
||||
request({
|
||||
action: 'broadcast',
|
||||
data: {
|
||||
type: 'browser_notification',
|
||||
title: 'Radio started',
|
||||
body: store.getState().pusher.username +' started radio mode',
|
||||
icon: ''
|
||||
}
|
||||
})
|
||||
.then(
|
||||
response => {
|
||||
if (response.data.error){
|
||||
console.error(response.data.error)
|
||||
return false
|
||||
}
|
||||
|
||||
store.dispatch(uiActions.createNotification('Starting radio...'))
|
||||
}
|
||||
)
|
||||
case 'PUSHER_START_RADIO':
|
||||
store.dispatch(uiActions.createNotification('Starting radio...'))
|
||||
|
||||
var data = {
|
||||
action: 'start_radio',
|
||||
seed_artists: [],
|
||||
seed_genres: [],
|
||||
seed_tracks: []
|
||||
@ -319,38 +282,27 @@ const PusherMiddleware = (function(){
|
||||
}
|
||||
}
|
||||
|
||||
request( data )
|
||||
request( 'start_radio', data )
|
||||
.then(response => {
|
||||
console.log(response)
|
||||
})
|
||||
break
|
||||
|
||||
case 'STOP_RADIO':
|
||||
|
||||
request({
|
||||
action: 'broadcast',
|
||||
data: {
|
||||
type: 'browser_notification',
|
||||
title: 'Radio stopped',
|
||||
body: store.getState().pusher.username +' stopped radio mode',
|
||||
icon: ''
|
||||
}
|
||||
})
|
||||
.then(
|
||||
response => {
|
||||
if (response.data.error){
|
||||
console.error(response.data.error)
|
||||
return false
|
||||
}
|
||||
|
||||
store.dispatch(uiActions.createNotification('Stopping radio'))
|
||||
}
|
||||
)
|
||||
case 'PUSHER_STOP_RADIO':
|
||||
store.dispatch(uiActions.createNotification('Stopping radio'))
|
||||
|
||||
var data = {
|
||||
action: 'stop_radio',
|
||||
seed_artists: [],
|
||||
seed_genres: [],
|
||||
seed_tracks: []
|
||||
}
|
||||
request( data )
|
||||
|
||||
// we don't need to wait for request, as change will be broadcast
|
||||
request( 'stop_radio', data )
|
||||
break
|
||||
|
||||
case 'PUSHER_BROWSER_NOTIFICATION':
|
||||
store.dispatch(uiActions.createBrowserNotification(action))
|
||||
break
|
||||
|
||||
// This action is irrelevant to us, pass it on to the next middleware
|
||||
|
||||
@ -7,11 +7,11 @@ export default function reducer(pusher = {}, action){
|
||||
return Object.assign({}, pusher, { connected: false, connecting: true });
|
||||
|
||||
case 'PUSHER_CONNECTED':
|
||||
return Object.assign({}, pusher, {
|
||||
connected: true,
|
||||
connecting: false,
|
||||
connectionid: action.connection.connectionid,
|
||||
username: action.connection.username
|
||||
return Object.assign({}, pusher, {
|
||||
connected: true,
|
||||
connecting: false,
|
||||
connection_id: action.connection_id,
|
||||
username: action.username
|
||||
});
|
||||
|
||||
case 'PUSHER_DISCONNECTED':
|
||||
@ -20,30 +20,34 @@ export default function reducer(pusher = {}, action){
|
||||
case 'PUSHER_SET_PORT':
|
||||
return Object.assign({}, pusher, { port: action.port });
|
||||
|
||||
case 'PUSHER_USERNAME':
|
||||
return Object.assign({}, pusher, { username: action.data.username });
|
||||
case 'PUSHER_USERNAME_CHANGED':
|
||||
return Object.assign({}, pusher, { username: action.username });
|
||||
|
||||
case 'CONNECTIONS':
|
||||
return Object.assign({}, pusher, { connections: action.data.connections });
|
||||
|
||||
case 'CONNECTION_UPDATED':
|
||||
function byID(connection){
|
||||
return connection.connectionid == action.data.connection.connectionid;
|
||||
case 'PUSHER_CONNECTIONS':
|
||||
var connections = {}
|
||||
for (var i = 0; i < action.connections.length; i++){
|
||||
connections[action.connections[i].connection_id] = action.connections[i]
|
||||
}
|
||||
var connection = pusher.connections.find(byID);
|
||||
var index = pusher.connections.indexOf(connection);
|
||||
var connections = Object.assign([], pusher.connections);
|
||||
connections[index] = action.data.connection;
|
||||
|
||||
return Object.assign({}, pusher, { connections: connections });
|
||||
|
||||
case 'VERSION':
|
||||
case 'PUSHER_CONNECTION_ADDED':
|
||||
case 'PUSHER_CONNECTION_UPDATED':
|
||||
var connections = Object.assign({}, pusher.connections)
|
||||
connections[action.connection.connection_id] = action.connection
|
||||
return Object.assign({}, pusher, { connections: connections });
|
||||
|
||||
case 'PUSHER_CONNECTION_REMOVED':
|
||||
var connections = Object.assign({}, pusher.connections)
|
||||
delete connections[action.connection.connection_id]
|
||||
return Object.assign({}, pusher, { connections: connections });
|
||||
|
||||
case 'PUSHER_VERSION':
|
||||
return Object.assign({}, pusher, {
|
||||
version: action.data.version,
|
||||
version: action.version,
|
||||
upgrading: false
|
||||
});
|
||||
|
||||
case 'START_UPGRADE':
|
||||
case 'PUSHER_START_UPGRADE':
|
||||
return Object.assign({}, pusher, { upgrading: true });
|
||||
|
||||
default:
|
||||
|
||||
@ -119,14 +119,15 @@ function refreshToken( dispatch, getState ){
|
||||
})
|
||||
.then(
|
||||
response => {
|
||||
response.token_expiry = new Date().getTime() + ( response.expires_in * 1000 );
|
||||
response.source = 'mopidy';
|
||||
var token = response.spotify_token
|
||||
token.token_expiry = new Date().getTime() + ( token.expires_in * 1000 );
|
||||
token.source = 'mopidy';
|
||||
dispatch({
|
||||
type: 'SPOTIFY_TOKEN_REFRESHED',
|
||||
provider: 'mopidy-spotify',
|
||||
data: response
|
||||
data: token
|
||||
});
|
||||
resolve(response);
|
||||
resolve(token);
|
||||
},
|
||||
error => {
|
||||
dispatch({ type: 'SPOTIFY_DISCONNECTED' })
|
||||
|
||||
@ -87,13 +87,14 @@ const SpotifyMiddleware = (function(){
|
||||
break
|
||||
|
||||
// when radio returns
|
||||
case 'RADIO':
|
||||
case 'PUSHER_RADIO_STARTED':
|
||||
case 'PUSHER_RADIO_CHANGED':
|
||||
|
||||
next(action)
|
||||
|
||||
// only resolve if radio is enabled
|
||||
if( action.data.radio.enabled ){
|
||||
store.dispatch(spotifyActions.resolveRadioSeeds(action.data.radio))
|
||||
if( action.radio.enabled ){
|
||||
store.dispatch(spotifyActions.resolveRadioSeeds(action.radio))
|
||||
}
|
||||
break
|
||||
|
||||
|
||||
@ -215,6 +215,13 @@ export function closeModal(){
|
||||
* Subtle info/tooltip messages
|
||||
**/
|
||||
|
||||
export function createBrowserNotification( data ){
|
||||
return {
|
||||
type: 'BROWSER_NOTIFICATION',
|
||||
data: data
|
||||
}
|
||||
}
|
||||
|
||||
export function createNotification( content, type = 'default' ){
|
||||
return {
|
||||
type: 'CREATE_NOTIFICATION',
|
||||
|
||||
@ -21,7 +21,7 @@ const UIMiddleware = (function(){
|
||||
break
|
||||
|
||||
case 'PUSHER_CONNECTED':
|
||||
ReactGA.event({ category: 'Pusher', action: 'Connected', label: action.connection.username })
|
||||
ReactGA.event({ category: 'Pusher', action: 'Connected', label: action.connection_id+'/'+action.username })
|
||||
next(action)
|
||||
break
|
||||
|
||||
@ -210,8 +210,8 @@ const UIMiddleware = (function(){
|
||||
break
|
||||
|
||||
case 'VERSION':
|
||||
if( action.data.version.upgrade_available )
|
||||
store.dispatch( uiActions.createNotification( 'Version '+action.data.version.latest+' is available. See settings to upgrade.' ) )
|
||||
if( action.version.upgrade_available )
|
||||
store.dispatch( uiActions.createNotification( 'Version '+action.version.latest+' is available. See settings to upgrade.' ) )
|
||||
next( action )
|
||||
break
|
||||
|
||||
|
||||
@ -13,7 +13,7 @@ export default function reducer(ui = {}, action){
|
||||
case 'UI_SET':
|
||||
return Object.assign({}, ui, action.data)
|
||||
|
||||
case 'CONFIG':
|
||||
case 'PUSHER_CONFIG':
|
||||
return Object.assign({}, ui, { config: action.config })
|
||||
|
||||
case 'TOGGLE_SIDEBAR':
|
||||
@ -132,23 +132,26 @@ export default function reducer(ui = {}, action){
|
||||
});
|
||||
|
||||
case 'QUEUE_METADATA':
|
||||
case 'QUEUE_METADATA_CHANGED':
|
||||
var tracklist = Object.assign([], ui.current_tracklist)
|
||||
for( var i = 0; i < tracklist.length; i++ ){
|
||||
|
||||
// load our metadata (if we have any for that tlid)
|
||||
if (typeof(action.data.queue_metadata['tlid_'+tracklist[i].tlid]) !== 'undefined'){
|
||||
if (typeof(action.queue_metadata['tlid_'+tracklist[i].tlid]) !== 'undefined'){
|
||||
tracklist[i] = Object.assign(
|
||||
{},
|
||||
tracklist[i],
|
||||
action.data.queue_metadata['tlid_'+tracklist[i].tlid],
|
||||
action.queue_metadata['tlid_'+tracklist[i].tlid],
|
||||
)
|
||||
}
|
||||
}
|
||||
return Object.assign({}, ui, { current_tracklist: tracklist, queue_metadata: action.data.queue_metadata });
|
||||
return Object.assign({}, ui, { current_tracklist: tracklist, queue_metadata: action.queue_metadata });
|
||||
|
||||
case 'RADIO':
|
||||
case 'START_RADIO':
|
||||
return Object.assign({}, ui, { seeds_resolved: false }, { radio: action.data.radio })
|
||||
case 'PUSHER_RADIO':
|
||||
case 'PUSHER_RADIO_STARTED':
|
||||
case 'PUSHER_RADIO_CHANGED':
|
||||
case 'PUSHER_RADIO_STOPPED':
|
||||
return Object.assign({}, ui, { seeds_resolved: false }, { radio: action.radio })
|
||||
|
||||
case 'RADIO_SEEDS_RESOLVED':
|
||||
var radio = Object.assign({}, ui.radio, { resolved_seeds: action.resolved_seeds })
|
||||
@ -370,7 +373,7 @@ export default function reducer(ui = {}, action){
|
||||
case 'USER_PLAYLISTS_LOADED':
|
||||
var users = Object.assign([], ui.users)
|
||||
var playlists_uris = []
|
||||
if (users[action.key].playlists_uris) playlists_uris = users[action.key].playlists_uris
|
||||
if (users[action.key] && users[action.key].playlists_uris) playlists_uris = users[action.key].playlists_uris
|
||||
|
||||
var artist = Object.assign(
|
||||
{},
|
||||
|
||||
@ -25,22 +25,7 @@ class Debug extends React.Component{
|
||||
this.state = {
|
||||
mopidy_call: 'playlists.asList',
|
||||
mopidy_data: '{}',
|
||||
pusher_data: '{}'
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount(){
|
||||
if( this.props.connectionid ){
|
||||
var data = {
|
||||
action: "broadcast",
|
||||
data: {
|
||||
type: 'browser_notification',
|
||||
title: "Title",
|
||||
body: "Test notification",
|
||||
icon: "http://lorempixel.com/100/100/nature/"
|
||||
}
|
||||
}
|
||||
this.setState({ pusher_data: JSON.stringify(data) })
|
||||
pusher_data: '{"method":"broadcast","data":{"type":"browser_notification","title":"Testing","body":"This is my message"}}'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@ import PusherConnectionList from '../components/PusherConnectionList'
|
||||
import URISchemesList from '../components/URISchemesList'
|
||||
import VersionManager from '../components/VersionManager'
|
||||
import Header from '../components/Header'
|
||||
import Icon from '../components/Icon'
|
||||
import Thumbnail from '../components/Thumbnail'
|
||||
|
||||
import * as uiActions from '../services/ui/actions'
|
||||
@ -56,25 +57,33 @@ class Settings extends React.Component{
|
||||
}
|
||||
|
||||
renderConnectionStatus(service){
|
||||
if( this.props[service].connected ){
|
||||
if (service == 'Spotify' && !this.props[service.toLowerCase()].authorized){
|
||||
return (
|
||||
<span className="text green-text connection-status">
|
||||
<FontAwesome name="check" />
|
||||
<span className="red-text connection-status">
|
||||
<FontAwesome name="exclamation-triangle" />
|
||||
|
||||
Connected
|
||||
Not connected
|
||||
</span>
|
||||
)
|
||||
}else if( this.props[service].connecting ){
|
||||
} else if (this.props[service.toLowerCase()].connected){
|
||||
return (
|
||||
<span className="text grey-text connection-status">
|
||||
<span className="green-text connection-status">
|
||||
<FontAwesome name="check" />
|
||||
|
||||
{service}
|
||||
</span>
|
||||
)
|
||||
} else if (this.props[service.toLowerCase()].connecting){
|
||||
return (
|
||||
<span className="grey-text connection-status">
|
||||
<FontAwesome name="circle-o-notch" spin />
|
||||
|
||||
Connecting
|
||||
</span>
|
||||
)
|
||||
}else{
|
||||
} else {
|
||||
return (
|
||||
<span className="text red-text connection-status">
|
||||
<span className="red-text connection-status">
|
||||
<FontAwesome name="exclamation-triangle" />
|
||||
|
||||
Not connected
|
||||
@ -99,7 +108,7 @@ class Settings extends React.Component{
|
||||
<Link className="user" to={global.baseURL+'user/'+user.uri}>
|
||||
<Thumbnail circle={true} size="small" images={user.images} />
|
||||
<span className="user-name">
|
||||
{user.display_name ? user.display_name : user.username}
|
||||
{user.display_name ? user.display_name : user.id}
|
||||
{!this.props.spotify.authorized ? <span className="grey-text"> (limited access)</span> : null}
|
||||
</span>
|
||||
</Link>
|
||||
@ -127,6 +136,31 @@ class Settings extends React.Component{
|
||||
)
|
||||
}
|
||||
|
||||
renderApplyButton(){
|
||||
if (this.props.mopidy.host == this.state.mopidy_host && this.props.mopidy.port == this.state.mopidy_port) return null
|
||||
|
||||
return (
|
||||
<div className="field">
|
||||
<div className="name"></div>
|
||||
<div className="input">
|
||||
<button type="submit" className="secondary">Apply and reload</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
serviceStatus(service){
|
||||
if (this.props[service].connecting){
|
||||
return <span className="grey-text">Connecting</span>
|
||||
} else if (service == 'spotify' && !this.props[service].authorized){
|
||||
return <span className="orange-text">Not authorized</span>
|
||||
} else if (this.props[service].connected){
|
||||
return <span className="green-text">Connected</span>
|
||||
} else {
|
||||
return <span className="red-text">Disconnected</span>
|
||||
}
|
||||
}
|
||||
|
||||
render(){
|
||||
|
||||
var actions = (
|
||||
@ -142,14 +176,30 @@ class Settings extends React.Component{
|
||||
|
||||
<section>
|
||||
|
||||
<h4 className="underline">Mopidy</h4>
|
||||
<form onSubmit={(e) => this.setMopidyConfig(e)}>
|
||||
<div className="field">
|
||||
<div className="name">Status</div>
|
||||
<div className="input">
|
||||
{ this.renderConnectionStatus('mopidy') }
|
||||
</div>
|
||||
<div className="status">
|
||||
|
||||
<div className="item">
|
||||
<Icon name="server" />
|
||||
<h4>Mopidy</h4>
|
||||
{this.serviceStatus('mopidy')}
|
||||
</div>
|
||||
|
||||
<div className="item">
|
||||
<Icon name="connection" />
|
||||
<h4>Pusher</h4>
|
||||
{this.serviceStatus('pusher')}
|
||||
</div>
|
||||
|
||||
<div className="item">
|
||||
<Icon name="spotify" />
|
||||
<h4>Spotify</h4>
|
||||
{this.serviceStatus('spotify')}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<h4 className="underline">System</h4>
|
||||
<form onSubmit={(e) => this.setMopidyConfig(e)}>
|
||||
<div className="field">
|
||||
<div className="name">Username</div>
|
||||
<div className="input">
|
||||
@ -177,22 +227,11 @@ class Settings extends React.Component{
|
||||
onChange={ e => this.setState({ mopidy_port: e.target.value })}
|
||||
value={ this.state.mopidy_port } />
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<div className="name"></div>
|
||||
<div className="input">
|
||||
<button type="submit" className="secondary">Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
{this.renderApplyButton()}
|
||||
</form>
|
||||
|
||||
<h4 className="underline">Spotify</h4>
|
||||
<div className="field">
|
||||
<div className="name">Status</div>
|
||||
<div className="input">
|
||||
{ this.renderConnectionStatus('spotify') }
|
||||
</div>
|
||||
</div>
|
||||
<form>
|
||||
<div className="field">
|
||||
<div className="name">Country</div>
|
||||
|
||||
@ -45,5 +45,34 @@ main {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include responsive( $bp_medium ){
|
||||
.dropdown-field {
|
||||
.label {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
|
||||
.text {
|
||||
display: none;
|
||||
}
|
||||
.fa {
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.actions {
|
||||
button {
|
||||
font-size: 1px;
|
||||
color: transparent;
|
||||
vertical-align: top;
|
||||
|
||||
.fa {
|
||||
font-size: 14px;
|
||||
color: #000000;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -167,6 +167,7 @@ h4 {
|
||||
.grey-text { color: $mid_grey !important; }
|
||||
.red-text { color: $red !important; }
|
||||
.green-text { color: $green !important; }
|
||||
.orange-text { color: $orange !important; }
|
||||
.dark-text { color: $dark_grey !important; }
|
||||
.centred-text { text-align: center; }
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@ $red: #cf2d2d;
|
||||
$green: #47af2a;
|
||||
$blue: #32b5f2;
|
||||
$yellow: #FFF39C;
|
||||
$orange: #f16f19;
|
||||
|
||||
$bp_wide: 1000px;
|
||||
$bp_medium: 800px;
|
||||
|
||||
@ -3,6 +3,29 @@
|
||||
section {
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.status {
|
||||
.item {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
padding: 0 0 0 45px;
|
||||
margin-right: 20px;
|
||||
min-width: 80px;
|
||||
|
||||
.icon {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
h4 {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.field.current-user {
|
||||
.text {
|
||||
|
||||
Reference in New Issue
Block a user