Snapcast using indexes; Broadlink serversidde API

This commit is contained in:
James Barnsley
2018-09-27 20:43:42 +12:00
parent efcdfd79fc
commit 35a48c2602
18 changed files with 11752 additions and 14445 deletions

View File

@ -40,6 +40,9 @@ class Extension( ext.Extension ):
schema['snapcast_enabled'] = config.Boolean()
schema['snapcast_host'] = config.String()
schema['snapcast_port'] = config.Integer()
schema['broadlink_enabled'] = config.Boolean()
schema['broadlink_host'] = config.String()
schema['broadlink_port'] = config.Integer()
return schema
def setup(self, registry):

View File

@ -181,6 +181,41 @@ class IrisCore(object):
return
##
# Send a command to the broadlink API
# Credentials for this API are defined in Mopidy config file.
#
# @return String
##
def send_broadlink_command(self, *args, **kwargs):
callback = kwargs.get('callback', None)
data = kwargs.get('data', {})
# Use client_id and client_secret from config
# This was introduced in Mopidy-Spotify 3.1.0
url = 'http://'+self.config['iris']['broadlink_host']+':'+str(self.config['iris']['broadlink_port'])+"/"
url+= data['command']
try:
http_client = tornado.httpclient.HTTPClient()
request = tornado.httpclient.HTTPRequest(url)
response = http_client.fetch(request)
response = json.loads(response.body)
if (callback):
callback(response)
else:
return response
except urllib2.HTTPError as e:
error = json.loads(e.read())
error = {'message': 'Could not send Broadlink command: '+error['error_description']}
if (callback):
callback(False, error)
else:
return error
##

View File

@ -8,3 +8,6 @@ genius_authorization_url = https://jamesbarnsley.co.nz/iris/auth_genius.php
snapcast_enabled = false
snapcast_host = localhost
snapcast_port = 1705
broadlink_enabled = false
broadlink_host = localhost
broadlink_port = 8080

View File

@ -4732,7 +4732,7 @@ $icon-websocket: \e920;
.output-control.disabled > .control {
opacity: 0.2;
cursor: not-allowed; }
.output-control .outputs {
.output-control__items {
position: absolute;
bottom: 40px;
right: -150px;
@ -4745,7 +4745,7 @@ $icon-websocket: \e920;
border-radius: 3px;
box-shadow: 2px 3px 6px rgba(0, 0, 0, 0.2);
z-index: 9; }
.output-control .outputs:after {
.output-control__items:after {
content: '';
display: block;
width: 6px;
@ -4757,39 +4757,116 @@ $icon-websocket: \e920;
position: absolute;
bottom: -3px;
right: 159px; }
.output-control .outputs .no-results {
.output-control__items .no-results {
font-size: 1rem;
text-align: center;
opacity: 0.5;
font-weight: normal; }
.output-control .outputs .output {
padding: 16px 20px 12px;
position: relative; }
.output-control .outputs .output:not(:first-child) {
border-top: 1px solid rgba(0, 0, 0, 0.05); }
.output-control .outputs .output .name {
padding-bottom: 5px; }
.output-control .outputs .output .actions {
float: right; }
.output-control .outputs .output .actions .action {
-webkit-transition: all 0.2s ease-in-out;
-moz-transition: all 0.2s ease-in-out;
-o-transition: all 0.2s ease-in-out;
transition: all 0.2s ease-in-out;
cursor: pointer;
color: #a3a19f; }
.output-control .outputs .output .actions .action:hover {
color: #181818; }
.output-control .outputs .output .volume-control {
.output-control__item {
padding: 16px 20px 12px;
position: relative; }
.output-control__item:not(:first-child) {
border-top: 1px solid rgba(0, 0, 0, 0.05); }
.output-control__item__name {
padding-bottom: 5px; }
.output-control__item__actions {
float: right; }
.output-control__item__actions .action {
-webkit-transition: all 0.2s ease-in-out;
-moz-transition: all 0.2s ease-in-out;
-o-transition: all 0.2s ease-in-out;
transition: all 0.2s ease-in-out;
cursor: pointer;
color: #a3a19f; }
.output-control__item__actions .action:hover {
color: #181818; }
.output-control__item__details {
display: block;
position: relative;
padding-left: 50px; }
.output-control__item__power {
position: relative;
position: absolute !important;
top: -2px;
left: -2px;
width: 18px;
height: 10px;
border-radius: 50%;
padding: 2px 0 6px 0;
font-size: 10px;
text-align: center;
color: #FFFFFF;
background: #f4f2f0;
cursor: pointer; }
@keyframes click_feedback {
0% {
top: 50%;
left: 0;
width: 100%;
padding-bottom: 100%;
opacity: 1; }
100% {
top: 25%;
left: -25%;
width: 150%;
padding-bottom: 150%;
opacity: 0; } }
.output-control__item__power:after {
content: '';
display: block;
position: relative;
padding-left: 22px; }
.output-control .outputs .output .volume-control .mute-control {
position: absolute;
top: -2px;
left: -2px;
padding: 2px;
color: #000000; }
position: absolute;
background: rgba(0, 0, 0, 0.5);
z-index: 1;
opacity: 0;
border-radius: 50%;
margin-top: -50%;
pointer-events: none; }
.output-control__item__power:focus:after, .output-control__item__power:active:after {
animation-name: click_feedback;
animation-duration: 0.2s;
animation-timing-function: ease-out;
animation-iteration-count: 1; }
.output-control__item__power--on {
color: #cf2d2d; }
.output-control__item__power--off {
color: #47af2a; }
.output-control__item__volume .mute-control {
position: relative;
position: absolute !important;
top: -2px;
left: 24px;
padding: 2px;
color: #000000; }
@keyframes click_feedback {
0% {
top: 50%;
left: 0;
width: 100%;
padding-bottom: 100%;
opacity: 1; }
100% {
top: 25%;
left: -25%;
width: 150%;
padding-bottom: 150%;
opacity: 0; } }
.output-control__item__volume .mute-control:after {
content: '';
display: block;
position: absolute;
background: rgba(0, 0, 0, 0.5);
z-index: 1;
opacity: 0;
border-radius: 50%;
margin-top: -50%;
pointer-events: none; }
.output-control__item__volume .mute-control:focus:after, .output-control__item__volume .mute-control:active:after {
animation-name: click_feedback;
animation-duration: 0.2s;
animation-timing-function: ease-out;
animation-iteration-count: 1; }
@media (max-width: 800px) {
.output-control .outputs {
width: 86vw;
@ -5973,12 +6050,10 @@ main header {
content: '';
clear: both;
display: block; }
.snapcast__client--disconnected .snapcast__client__header {
opacity: 0.5; }
.snapcast__client--expanded {
background: rgba(255, 255, 255, 0.1); }
background: rgba(255, 255, 255, 0.05); }
.light-theme .snapcast__client--expanded {
background: rgba(0, 0, 0, 0.1); }
background: rgba(0, 0, 0, 0.05); }
.snapcast__client--collapsed .snapcast__client__header:hover {
background: rgba(255, 255, 255, 0.03); }
.light-theme .snapcast__client--collapsed .snapcast__client__header:hover {
@ -5997,6 +6072,8 @@ main header {
padding: 14px 10px; }
.snapcast__client__header__icons .icon {
padding-left: 6px; }
.snapcast__client__header__icons .icon.disconnected {
color: #cf2d2d; }
.snapcast__client__details {
position: relative;
padding: 0 14px 14px 14px; }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -98,7 +98,7 @@
// Release details
// These are automatically injected to built HTML
var build = "1537819388";
var build = "1537907593";
var version = "3.26.2";
// Construct the script tag

5
src/js/bootstrap.js vendored
View File

@ -85,7 +85,10 @@ var initialState = {
authorization_url: 'https://jamesbarnsley.co.nz/iris/auth_spotify.php'
},
snapcast: {
streams: {},
groups: {},
clients: {},
server: null
}
};

View File

@ -4,11 +4,13 @@ import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import VolumeControl from './VolumeControl'
import SnapcastPowerButton from './SnapcastPowerButton'
import Icon from '../Icon'
import * as helpers from '../../helpers'
import * as coreActions from '../../services/core/actions'
import * as pusherActions from '../../services/pusher/actions'
import * as helpers from '../../helpers';
import * as coreActions from '../../services/core/actions';
import * as pusherActions from '../../services/pusher/actions';
import * as snapcastActions from '../../services/snapcast/actions';
class OutputControl extends React.Component{
@ -51,9 +53,9 @@ class OutputControl extends React.Component{
for (var key in this.props.snapcast_clients){
if (this.props.snapcast_clients.hasOwnProperty(key)){
var client = this.props.snapcast_clients[key];
if (client.connected){
//if (client.connected){
clients.push(client);
}
//}
}
}
@ -63,20 +65,24 @@ class OutputControl extends React.Component{
<div>
{
clients.map(client => {
var name = client.config.name ? client.config.name : client.host.name;
return (
<div className="output snapcast-output" key={client.id}>
<div className="name">
{name}
<div className="output-control__item outputs__item--snapcast" key={client.id}>
<div className="output-control__item__name">
{client.name}
</div>
<div className="output-control__item__details">
{client.commands && client.commands.power_on ? <SnapcastPowerButton
className="output-control__item__power output-control__item__power--on"
client={client}
/> : null}
<VolumeControl
className="output-control__item__volume"
volume={client.volume}
mute={client.mute}
onVolumeChange={percent => this.props.snapcastActions.setClientVolume(client.id, percent)}
onMuteChange={mute => this.props.snapcastActions.setClientMute(client.id, mute)}
/>
</div>
<VolumeControl
className="client-volume-control"
volume={client.config.volume.percent}
mute={client.config.volume.muted}
onVolumeChange={percent => this.props.pusherActions.setSnapcastClientVolume(client.id, percent)}
onMuteChange={mute => this.props.pusherActions.setSnapcastClientMute(client.id, mute)}
/>
</div>
);
})
@ -88,35 +94,37 @@ class OutputControl extends React.Component{
var local_streaming = null;
if (this.props.http_streaming_enabled){
local_streaming = (
<div className="output icecast-output">
<div className="actions">
<span className="action" onClick={e => this.props.coreActions.cachebustHttpStream()}>
<div className="output-control__item outputs__item--icecast">
<div className="output-control__item__actions">
<span className="output-control__item__action" onClick={e => this.props.coreActions.cachebustHttpStream()}>
<Icon name="refresh" />
</span>
</div>
<div className="name">
<div className="output-control__item__name">
Local browser
</div>
<VolumeControl
className="client-volume-control"
volume={this.props.http_streaming_volume}
mute={this.props.http_streaming_mute}
onVolumeChange={percent => this.props.coreActions.set({http_streaming_volume: percent})}
onMuteChange={mute => this.props.coreActions.set({http_streaming_mute: mute})}
/>
<div className="output-control__item__details">
<VolumeControl
className="output-control__item__volume"
volume={this.props.http_streaming_volume}
mute={this.props.http_streaming_mute}
onVolumeChange={percent => this.props.coreActions.set({http_streaming_volume: percent})}
onMuteChange={mute => this.props.coreActions.set({http_streaming_mute: mute})}
/>
</div>
</div>
);
}
if (!local_streaming && !snapcast_clients){
return (
<div className="outputs">
<div className="output-control__items">
<p className="no-results">No outputs</p>
</div>
);
} else {
return (
<div className="outputs">
<div className="output-control__items">
{local_streaming}
{snapcast_clients}
</div>
@ -159,13 +167,14 @@ const mapStateToProps = (state, ownProps) => {
http_streaming_mute: state.core.http_streaming_mute,
snapcast_enabled: (state.pusher.config ? state.pusher.config.snapcast_enabled : null),
pusher_connected: state.pusher.connected,
snapcast_clients: state.pusher.snapcast_clients
snapcast_clients: state.snapcast.clients
}
}
const mapDispatchToProps = (dispatch) => {
return {
coreActions: bindActionCreators(coreActions, dispatch),
snapcastActions: bindActionCreators(snapcastActions, dispatch),
pusherActions: bindActionCreators(pusherActions, dispatch)
}
}

View File

@ -0,0 +1,44 @@
import React, { PropTypes } from 'react'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import Icon from '../Icon'
import * as helpers from '../../helpers';
class SnapcastPowerButton extends React.Component{
constructor(props){
super(props);
this.handleClick = this.handleClick.bind(this);
}
handleClick(e){
var command = JSON.parse(this.props.client.commands.power_on);
$.ajax(command);
}
render(){
var classname = "snapcast-power-button";
if (this.props.className){
classname += " "+this.props.className;
}
return (
<span className={classname} onClick={e => this.handleClick(e)}>
<Icon name="power_settings_new" />
</span>
);
}
}
const mapStateToProps = (state, ownProps) => {
return {}
}
const mapDispatchToProps = (dispatch) => {
return {}
}
export default connect(mapStateToProps, mapDispatchToProps)(SnapcastPowerButton)

View File

@ -61,7 +61,13 @@ class Snapcast extends React.Component{
renderClientsList(group, groups){
if (!group.clients || group.clients.length <= 0){
if (!this.props.show_disconnected_clients && group.clients){
var clients = helpers.applyFilter('connected', true, group.clients);
} else {
var clients = group.clients;
}
if (!clients || clients.length <= 0){
return (
<div className="text grey-text">
No clients
@ -72,9 +78,7 @@ class Snapcast extends React.Component{
return (
<div className="list snapcast__clients">
{
group.clients.map(client => {
var name = client.config.name ? client.config.name : client.host.name;
clients.map(client => {
var class_name = "list__item snapcast__client";
if (client.connected){
class_name += " snapcast__client--connected";
@ -86,7 +90,7 @@ class Snapcast extends React.Component{
return (
<div className={class_name+" snapcast__client--expanded"} key={client.id}>
<div className="snapcast__client__header" onClick={e => this.toggleClientExpanded(client.id)}>
{name}
{client.name}
<div className="snapcast__client__header__icons">
{!client.connected ? <Icon name="power_off" className="disconnected" /> : null}
<Icon name="expand_less" />
@ -100,7 +104,7 @@ class Snapcast extends React.Component{
<div className="input">
<TextField
onChange={value => this.props.snapcastActions.setClientName(client.id, value)}
value={name}
value={client.name}
/>
</div>
</div>
@ -132,8 +136,8 @@ class Snapcast extends React.Component{
<div className="input">
<VolumeControl
className="snapcast__client__volume-control"
volume={client.config.volume.percent}
mute={client.config.volume.muted}
volume={client.volume}
mute={client.mute}
onVolumeChange={percent => this.props.snapcastActions.setClientVolume(client.id, percent)}
onMuteChange={mute => this.props.snapcastActions.setClientMute(client.id, mute)}
/>
@ -146,42 +150,44 @@ class Snapcast extends React.Component{
<div className="input">
<LatencyControl
max="100"
value={client.config.latency}
value={client.latency}
onChange={value => this.props.snapcastActions.setClientLatency(client.id, parseInt(value))}
/>
<TextField
className="tiny"
type="number"
onChange={value => this.props.snapcastActions.setClientLatency(client.id, parseInt(value))}
value={String(client.config.latency)}
value={String(client.latency)}
/>
</div>
</div>
<div className="field">
<div className="name tooltip">
Power on command
Power on request
<span className="tooltip__content">
Fire this HTTP request to turn power on
</span>
</div>
<div className="input">
<TextField
onChange={value => console.log(value)}
value="http://localhost:8080/sendCommand/power_on"
onChange={value => this.props.snapcastActions.setClientCommand(client.id, "power_on", value)}
value={client.power_on_command ? client.power_on_command : ""}
placeholder="Leave empty to disable"
/>
</div>
</div>
<div className="field">
<div className="name tooltip">
Power off command
Power off request
<span className="tooltip__content">
Fire this HTTP request to turn power off
</span>
</div>
<div className="input">
<TextField
onChange={value => console.log(value)}
value="http://localhost:8080/sendCommand/power_off"
onChange={value => this.props.snapcastActions.setClientCommand(client.id, "power_off", value)}
value={client.power_off_command ? client.power_off_command : ""}
placeholder="Leave empty to disable"
/>
</div>
</div>
@ -192,7 +198,7 @@ class Snapcast extends React.Component{
return (
<div className={class_name+" snapcast__client--collapsed"} key={client.id}>
<div className="snapcast__client__header" onClick={e => this.toggleClientExpanded(client.id)}>
{name}
{client.name}
<div className="snapcast__client__header__icons">
{!client.connected ? <Icon name="power_off" className="disconnected" /> : null}
<Icon name="expand_more" />
@ -214,45 +220,17 @@ class Snapcast extends React.Component{
)
}
if (!this.props.clients || !this.props.groups){
return (
<div className="lazy-loader body-loader loading">
<div className="loader"></div>
</div>
)
}
var streams = [];
for (var key in this.props.streams){
if (this.props.streams.hasOwnProperty(key)){
streams.push(this.props.streams[key]);
for (var id in this.props.streams){
if (this.props.streams.hasOwnProperty(id)){
streams.push(this.props.streams[id]);
}
}
// Construct a simple array of our groups index
var groups = [];
for (var group_id in this.props.groups){
if (this.props.groups.hasOwnProperty(group_id)){
var group = this.props.groups[group_id];
// Merge the group's clients into this group (also as a simple array)
var clients = [];
for (var i = 0; i < group.clients_ids.length; i++){
if (this.props.clients.hasOwnProperty(group.clients_ids[i])){
var client = this.props.clients[group.clients_ids[i]];
if (client.connected || this.props.show_disconnected_clients){
clients.push(client);
}
}
}
groups.push(Object.assign(
{},
group,
{
clients: clients
}
));
for (var id in this.props.groups){
if (this.props.groups.hasOwnProperty(id)){
groups.push(this.props.groups[id]);
}
}
@ -286,11 +264,13 @@ class Snapcast extends React.Component{
{
groups.map(group => {
group = helpers.collate(group, {clients: this.props.clients});
// Average our clients' volume for an overall group volume
var group_volume = 0;
for (var i = 0; i < group.clients.length; i++){
var client = group.clients[i];
group_volume += client.config.volume.percent;
group_volume += client.volume;
}
group_volume = group_volume / group.clients.length;
@ -302,7 +282,7 @@ class Snapcast extends React.Component{
</div>
<div className="input">
<div className="text">
{group.name ? group.name : 'Group '+group.id.substring(0,3)} &nbsp;
{group.name} &nbsp;
<span className="grey-text">({group.id})</span>
</div>
</div>

View File

@ -701,6 +701,91 @@ export let formatTrack = function(data){
return track;
}
/**
* Format a snapcast client object into a universal format
*
* @param data obj
* @return obj
**/
export let formatClient = function(data){
var client = {}
var fields = [
'id',
'connected',
'name',
'host_name',
'volume',
'mute',
'latency',
'power_on_command',
'power_off_command'
];
for (var field of fields){
if (data.hasOwnProperty(field)){
client[field] = data[field];
}
}
if (data.config){
if (client.latency === undefined && data.config.latency !== undefined){
client.latency = data.config.latency;
}
if (!client.name && data.config.name){
client.name = data.config.name;
}
if (data.config.volume){
if (data.config.volume.percent){
client.volume = data.config.volume.percent;
}
if (data.config.volume.muted){
client.mute = data.config.volume.muted;
}
}
}
if (client.name === undefined && data.host && data.host.name){
client.name = data.host.name;
}
return client;
}
/**
* Format a snapcast client object into a universal format
*
* @param data obj
* @return obj
**/
export let formatGroup = function(data){
var group = {}
var fields = [
'id',
'name',
'mute',
'stream_id',
'clients_ids'
];
for (var field of fields){
if (data.hasOwnProperty(field)){
group[field] = data[field];
}
}
if (!group.name && data.name){
group.name = 'Group '+data.id.substring(0,3);
}
if (group.mute === undefined && data.mute !== undefined){
group.mute = data.mute;
}
return group;
}
/**
* Collate an object with external references into a fully self-contained object
@ -726,6 +811,7 @@ export let collate = function(obj, indexes = {}){
if (obj.users_uris !== undefined) obj.users = [];
if (obj.playlists_uris !== undefined) obj.playlists = [];
if (obj.related_artists_uris !== undefined) obj.related_artists = [];
if (obj.clients_ids !== undefined) obj.clients = [];
if (indexes.artists){
if (obj.artists_uris){
@ -809,6 +895,16 @@ export let collate = function(obj, indexes = {}){
}
}
if (indexes.clients){
if (obj.clients_ids){
for (var id of obj.clients_ids){
if (indexes.clients[id]){
obj.clients.push(indexes.clients[id]);
}
}
}
}
return obj;
}

View File

@ -1,49 +1,4 @@
export function connect(){
return {
type: 'SNAPCAST_CONNECT'
}
}
export function disconnect(){
return {
type: 'SNAPCAST_DISCONNECT'
}
}
export function reload(){
return {
type: 'SNAPCAST_RELOAD'
}
}
export function getConnections(){
return {
type: 'SNAPCAST_GET_CONNECTIONS'
}
}
export function connectionAdded(connection){
return {
type: 'SNAPCAST_CONNECTION_ADDED',
connection: connection
}
}
export function connectionChanged(connection){
return {
type: 'SNAPCAST_CONNECTION_CHANGED',
connection: connection
}
}
export function connectionRemoved(connection){
return {
type: 'SNAPCAST_CONNECTION_REMOVED',
connection: connection
}
}
export function getServer(){
return {
type: 'SNAPCAST_GET_SERVER'
@ -66,11 +21,11 @@ export function setClientMute(id, mute){
}
}
export function setClientVolume(id, percent){
export function setClientVolume(id, volume){
return {
type: 'SNAPCAST_SET_CLIENT_VOLUME',
id: id,
percent: percent
volume: volume
}
}
@ -82,6 +37,15 @@ export function setClientLatency(id, latency){
}
}
export function setClientCommand(id, name, command){
return {
type: 'SNAPCAST_SET_CLIENT_COMMAND',
id: id,
name: name,
command: command
}
}
export function setClientGroup(id, group_id){
return {
type: 'SNAPCAST_SET_CLIENT_GROUP',
@ -132,12 +96,9 @@ export function setGroupVolume(id, percent, old_percent = 0){
**/
export function serverLoaded(server){
return serversLoaded([server]);
}
export function serversLoaded(servers){
return {
type: 'SNAPCAST_SERVERS_LOADED',
servers: servers
type: 'SNAPCAST_SERVER_LOADED',
server: server
}
}
@ -156,8 +117,8 @@ export function groupLoaded(group){
}
export function groupsLoaded(groups){
return {
type: 'SNAPCAST_SERVERS_LOADED',
servers: group
type: 'SNAPCAST_GROUPS_LOADED',
groups: groups
}
}

View File

@ -23,6 +23,8 @@ const SnapcastMiddleware = (function(){
}
return store => next => action => {
var snapcast = store.getState().snapcast;
switch(action.type){
case 'SNAPCAST_GET_SERVER':
@ -32,7 +34,8 @@ const SnapcastMiddleware = (function(){
method: 'Server.GetStatus'
},
response => {
store.dispatch(snapcastActions.serverLoaded(response.server));
console.log(response);
store.dispatch(snapcastActions.serverLoaded(response.server));
},
error => {
store.dispatch(coreActions.handleException(
@ -44,45 +47,66 @@ const SnapcastMiddleware = (function(){
break
case 'SNAPCAST_SERVER_LOADED':
var groups = {};
var clients = {};
var streams = {};
store.dispatch(snapcastActions.groupsLoaded(action.server.groups));
store.dispatch(snapcastActions.streamsLoaded(action.server.streams));
// Loop all the groups
for (var i = 0; i < action.server.groups.length; i++){
var group = action.server.groups[i];
var clients_ids = [];
// Snapcast double-nests the server object
action.server = action.server.server;
// And now this groups' clients
for (var j = 0; j < group.clients.length; j++){
var client = group.clients[j];
clients[client.id] = client;
clients_ids.push(client.id);
}
groups[group.id] = {
id: group.id,
muted: group.muted,
name: group.name,
stream_id: group.stream_id,
clients_ids: clients_ids
}
}
// Loop all the streams
for (var i = 0; i < action.server.streams.length; i++){
var stream = action.server.streams[i];
streams[stream.id] = stream;
}
action.clients = clients;
action.groups = groups;
action.streams = streams;
next(action);
break
case 'SNAPCAST_GROUPS_LOADED':
var groups_index = Object.assign({}, snapcast.groups);
var groups_loaded = [];
var clients_loaded = [];
for (var raw_group of action.groups){
var group = helpers.formatGroup(raw_group);
if (groups_index[group.id]){
group = Object.assign({}, groups_index[group.id], group);
}
if (raw_group.clients){
group.clients_ids = helpers.arrayOf('id', raw_group.clients);
clients_loaded = [...clients_loaded, ...raw_group.clients];
}
groups_loaded.push(group);
};
action.groups = groups_loaded;
if (clients_loaded.length > 0){
store.dispatch(snapcastActions.clientsLoaded(clients_loaded));
}
next(action);
break
case 'SNAPCAST_CLIENTS_LOADED':
var clients_index = Object.assign({}, snapcast.clients);
var clients_loaded = [];
for (var raw_client of action.clients){
var client = helpers.formatClient(raw_client);
if (clients_index[client.id]){
client = Object.assign({}, clients_index[client.id], client);
}
clients_loaded.push(client);
}
action.clients = clients_loaded;
next(action);
break;
case 'SNAPCAST_SET_CLIENT_NAME':
var client = store.getState().snapcast.clients[action.id];
var client = snapcast.clients[action.id];
var data = {
method: 'Client.SetName',
params: {
@ -95,15 +119,12 @@ const SnapcastMiddleware = (function(){
store,
data,
response => {
store.dispatch({
type: 'SNAPCAST_CLIENT_UPDATED',
key: action.id,
client: {
config: {
name: response.name
}
store.dispatch(snapcastActions.clientLoaded(
{
id: action.id,
name: response.name
}
});
));
}
);
break
@ -116,7 +137,7 @@ const SnapcastMiddleware = (function(){
id: action.id,
volume: {
muted: action.mute,
percent: client.config.volume.percent,
percent: client.volume,
}
}
}
@ -125,15 +146,13 @@ const SnapcastMiddleware = (function(){
store,
data,
response => {
store.dispatch({
type: 'SNAPCAST_CLIENT_UPDATED',
key: action.id,
client: {
config: {
volume: response.volume
}
store.dispatch(snapcastActions.clientLoaded(
{
id: action.id,
volume: response.volume.percent,
mute: response.volume.muted
}
})
));
},
error => {
store.dispatch(coreActions.handleException(
@ -146,14 +165,15 @@ const SnapcastMiddleware = (function(){
break
case 'SNAPCAST_SET_CLIENT_VOLUME':
var client = store.getState().snapcast.clients[action.id];
var client = snapcast.clients[action.id];
var data = {
method: 'Client.SetVolume',
params: {
id: action.id,
volume: {
muted: client.config.volume.muted,
percent: action.percent
muted: client.mute,
percent: action.volume
}
}
}
@ -162,15 +182,12 @@ const SnapcastMiddleware = (function(){
store,
data,
response => {
store.dispatch({
type: 'SNAPCAST_CLIENT_UPDATED',
key: action.id,
client: {
config: {
volume: response.volume
}
store.dispatch(snapcastActions.clientLoaded(
{
id: action.id,
volume: response.volume.percent
}
})
));
},
error => {
store.dispatch(coreActions.handleException(
@ -193,18 +210,15 @@ const SnapcastMiddleware = (function(){
}
request(
store,
data,
store,
data,
response => {
store.dispatch({
type: 'SNAPCAST_CLIENT_UPDATED',
key: action.id,
client: {
config: {
latency: response.latency
}
store.dispatch(snapcastActions.clientLoaded(
{
id: action.id,
latency: response.latency
}
})
));
},
error => {
store.dispatch(coreActions.handleException(
@ -216,9 +230,18 @@ const SnapcastMiddleware = (function(){
);
break
case 'SNAPCAST_SET_CLIENT_COMMAND':
var client = snapcast.clients[action.id];
if (!client.commands){
client.commands = {};
}
client.commands[action.name] = action.command;
store.dispatch(snapcastActions.clientLoaded(client));
break
case 'SNAPCAST_SET_CLIENT_GROUP':
var group = store.getState().snapcast.groups[action.group_id];
var group = snapcast.groups[action.group_id];
var clients_ids = group.clients_ids;
var clients_ids_index = clients_ids.indexOf(action.id);
@ -296,13 +319,12 @@ const SnapcastMiddleware = (function(){
store,
data,
response => {
store.dispatch({
type: 'SNAPCAST_GROUP_UPDATED',
key: action.id,
group: {
store.dispatch(snapcastActions.groupLoaded(
{
id: action.id,
stream_id: action.stream_id
}
})
));
},
error => {
store.dispatch(coreActions.handleException(
@ -328,13 +350,12 @@ const SnapcastMiddleware = (function(){
store,
data,
response => {
store.dispatch({
type: 'SNAPCAST_GROUP_UPDATED',
key: action.id,
group: {
store.dispatch(snapcastActions.groupLoaded(
{
id: action.id,
muted: response.mute
}
})
));
},
error => {
store.dispatch(coreActions.handleException(
@ -348,38 +369,37 @@ const SnapcastMiddleware = (function(){
case 'SNAPCAST_SET_GROUP_VOLUME':
var clients_to_update = [];
var group = store.getState().snapcast.groups[action.id];
var group = snapcast.groups[action.id];
var change = action.percent - action.old_percent;
for (var i = 0; i < group.clients_ids.length; i++){
for (var client_id of group.clients_ids){
// Apply the change proportionately to each client
var client = store.getState().snapcast.clients[group.clients_ids[i]];
var current_percent = client.config.volume.percent;
var new_percent = current_percent + change;
var client = snapcast.clients[client_id];
var current_volume = client.volume;
var new_volume = current_volume + change;
// Only change if the client is within min/max limits
if ((change > 0 && current_percent < 100) || (change < 0 && current_percent > 0)){
if ((change > 0 && current_volume < 100) || (change < 0 && current_volume > 0)){
clients_to_update.push({
id: client.id,
percent: new_percent
volume: new_volume
});
}
}
// Loop our required changes, and post each to Snapcast
for (var i = 0; i < clients_to_update.length; i++){
var update = clients_to_update[i];
var percent = update.percent + ((group.clients_ids.length - clients_to_update.length) * change);
for (var client_to_update of clients_to_update){
var volume = client_to_update.volume + ((group.clients_ids.length - clients_to_update.length) * change);
// Make sure we're not creating an impossible percent
if (percent < 0){
percent = 0;
} else if (percent > 100){
percent = 100;
if (volume < 0){
volume = 0;
} else if (volume > 100){
volume = 100;
}
store.dispatch(snapcastActions.setClientVolume(update.id, percent));
store.dispatch(snapcastActions.setClientVolume(client_to_update.id, volume));
}
// This action is irrelevant to us, pass it on to the next middleware

View File

@ -2,27 +2,31 @@
export default function reducer(snapcast = {}, action){
switch (action.type){
case 'SNAPCAST_SERVER_LOADED':
var server = Object.assign({}, action.server);
return Object.assign({}, snapcast, { server: server });
case 'SNAPCAST_CLIENTS_LOADED':
var clients = Object.assign({}, snapcast.clients);
for (var client of action.clients){
clients[client.uri] = client;
clients[client.id] = client;
}
return Object.assign({}, snapcast, { clients: clients });
case 'SNAPCAST_SOURCES_LOADED':
var sources = Object.assign({}, snapcast.sources);
for (var source of action.sources){
sources[source.uri] = source;
}
return Object.assign({}, snapcast, { sources: sources });
case 'SNAPCAST_GROUPS_LOADED':
var groups = Object.assign({}, snapcast.groups);
for (var group of action.groups){
groups[group.uri] = group;
groups[group.id] = group;
}
return Object.assign({}, snapcast, { groups: groups });
case 'SNAPCAST_STREAMS_LOADED':
var streams = Object.assign({}, snapcast.streams);
for (var stream of action.streams){
streams[stream.id] = stream;
}
return Object.assign({}, snapcast, { streams: streams });
default:
return snapcast
}

View File

@ -9,7 +9,7 @@
}
}
.outputs {
&__items {
position: absolute;
bottom: 40px;
right: -150px;
@ -43,45 +43,72 @@
opacity: 0.5;
font-weight: normal;
}
}
.output {
padding: 16px 20px 12px;
&__item {
padding: 16px 20px 12px;
position: relative;
&:not(:first-child) {
border-top: 1px solid rgba(0,0,0,0.05);
}
&__name {
padding-bottom: 5px;
}
&__actions {
float: right;
.action {
@include animate();
cursor: pointer;
color: $mid_grey;
&:hover {
color: $dark_grey;
}
}
}
&__details {
display: block;
position: relative;
padding-left: 50px;
}
&:not(:first-child) {
border-top: 1px solid rgba(0,0,0,0.05);
&__power {
@include click_feedback(rgba(0,0,0,0.5));
position: absolute !important;
top: -2px;
left: -2px;
width: 18px;
height: 10px;
border-radius: 50%;
padding: 2px 0 6px 0;
font-size: 10px;
text-align: center;
color: $white;
background: $faint_grey;
cursor: pointer;
&--on {
color: $red;
}
.name {
padding-bottom: 5px;
&--off {
color: $green;
}
}
.actions {
float: right;
.action {
@include animate();
cursor: pointer;
color: $mid_grey;
&:hover {
color: $dark_grey;
}
}
}
.volume-control {
display: block;
position: relative;
padding-left: 22px;
.mute-control {
position: absolute;
top: -2px;
left: -2px;
padding: 2px;
color: $black;
}
&__volume {
.mute-control {
@include click_feedback(rgba(0,0,0,0.5));
position: absolute !important;
top: -2px;
left: 24px;
padding: 2px;
color: $black;
}
}
}

View File

@ -46,17 +46,11 @@
@include clearfix();
padding: 0;
&--disconnected {
.snapcast__client__header {
opacity: 0.5;
}
}
&--expanded {
background: rgba(255,255,255,0.1);
background: rgba(255,255,255,0.05);
.light-theme & {
background: rgba(0,0,0,0.1);
background: rgba(0,0,0,0.05);
}
}
@ -86,6 +80,10 @@
.icon {
padding-left: 6px;
&.disconnected {
color: $red;
}
}
}
}

View File

@ -140,7 +140,7 @@ $bp_shallow: 650px;
animation-iteration-count: 1;
}
@mixin click_feedback($duration: 0.2s, $easing: ease-out){
@mixin click_feedback($colour: rgba(255,255,255,0.5), $duration: 0.2s, $easing: ease-out){
position: relative;
@keyframes click_feedback {
@ -164,7 +164,7 @@ $bp_shallow: 650px;
content: '';
display: block;
position: absolute;
background: rgba(255,255,255,0.5);
background: $colour;
z-index: 1;
opacity: 0;
border-radius: 50%;