├── static
├── js
│ ├── queue.js
│ ├── sonos.js
│ ├── gui.js
│ ├── socket.js
│ ├── volume-slider.js
│ ├── progress-bar.js
│ ├── gui-events.js
│ └── all.js
├── favicon.ico
├── images
│ ├── s_bloom.png
│ ├── s_bloom_20.png
│ ├── pause_normal.png
│ ├── play_normal.png
│ ├── vol_scrubber_normal.png
│ ├── wdcr_zm_ic_bedroom.png
│ ├── tc_vol_speakers_normal.png
│ ├── browse_missing_album_art.png
│ ├── tc_now_playing_fwd_normal.png
│ ├── tc_now_playing_rwd_normal.png
│ ├── popover_vol_scrubber_normal.png
│ ├── tc_progress_container_left.png
│ ├── tc_progress_container_right.png
│ ├── tc_progress_container_center.png
│ ├── tc_progress_equalizer_normal.png
│ ├── tc_progress_repeat_normal_off.png
│ ├── tc_progress_repeat_normal_on.png
│ ├── tc_progress_shuffle_normal_off.png
│ ├── tc_progress_shuffle_normal_on.png
│ ├── tc_progressbar_scrubber_normal.png
│ ├── tc_progress_crossfade_normal_off.png
│ ├── tc_progress_crossfade_normal_on.png
│ └── tc_progressbar_scrubber_selected.png
├── css
│ ├── sonoshandbookpro-light-webfont.woff
│ ├── sonoshandbookpro-medium-webfont.woff
│ ├── sonoshandbookpro-regular-webfont.woff
│ ├── common.css
│ ├── font.css
│ └── main.css
├── m
│ ├── css
│ │ ├── main.css
│ │ ├── portrait.css
│ │ ├── landscape.css
│ │ └── common.css
│ ├── js
│ │ ├── socket-events.js
│ │ ├── gui-events.js
│ │ ├── touch-volume.js
│ │ └── gui.js
│ └── index.html
├── svg
│ ├── trans_gradient_mask.svg
│ ├── pause.svg
│ ├── play.svg
│ ├── mute_on.svg
│ ├── mute_off.svg
│ ├── prev.svg
│ └── next.svg
├── spotify
│ ├── index.html
│ └── css
│ │ └── spotify.css
├── dashboard
│ ├── dashboard.css
│ └── index.html
└── index.html
├── .gitignore
├── lib
└── browse_missing_album_art.png
├── package.json
├── LICENSE.md
├── README.md
└── server.js
/static/js/queue.js:
--------------------------------------------------------------------------------
1 | "use strict";
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | cache
3 | settings.json
4 | .DS_Store
5 | .idea
6 |
--------------------------------------------------------------------------------
/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jishi/node-sonos-web-controller/HEAD/static/favicon.ico
--------------------------------------------------------------------------------
/static/images/s_bloom.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jishi/node-sonos-web-controller/HEAD/static/images/s_bloom.png
--------------------------------------------------------------------------------
/static/images/s_bloom_20.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jishi/node-sonos-web-controller/HEAD/static/images/s_bloom_20.png
--------------------------------------------------------------------------------
/static/images/pause_normal.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jishi/node-sonos-web-controller/HEAD/static/images/pause_normal.png
--------------------------------------------------------------------------------
/static/images/play_normal.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jishi/node-sonos-web-controller/HEAD/static/images/play_normal.png
--------------------------------------------------------------------------------
/lib/browse_missing_album_art.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jishi/node-sonos-web-controller/HEAD/lib/browse_missing_album_art.png
--------------------------------------------------------------------------------
/static/images/vol_scrubber_normal.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jishi/node-sonos-web-controller/HEAD/static/images/vol_scrubber_normal.png
--------------------------------------------------------------------------------
/static/images/wdcr_zm_ic_bedroom.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jishi/node-sonos-web-controller/HEAD/static/images/wdcr_zm_ic_bedroom.png
--------------------------------------------------------------------------------
/static/images/tc_vol_speakers_normal.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jishi/node-sonos-web-controller/HEAD/static/images/tc_vol_speakers_normal.png
--------------------------------------------------------------------------------
/static/images/browse_missing_album_art.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jishi/node-sonos-web-controller/HEAD/static/images/browse_missing_album_art.png
--------------------------------------------------------------------------------
/static/images/tc_now_playing_fwd_normal.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jishi/node-sonos-web-controller/HEAD/static/images/tc_now_playing_fwd_normal.png
--------------------------------------------------------------------------------
/static/images/tc_now_playing_rwd_normal.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jishi/node-sonos-web-controller/HEAD/static/images/tc_now_playing_rwd_normal.png
--------------------------------------------------------------------------------
/static/images/popover_vol_scrubber_normal.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jishi/node-sonos-web-controller/HEAD/static/images/popover_vol_scrubber_normal.png
--------------------------------------------------------------------------------
/static/images/tc_progress_container_left.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jishi/node-sonos-web-controller/HEAD/static/images/tc_progress_container_left.png
--------------------------------------------------------------------------------
/static/images/tc_progress_container_right.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jishi/node-sonos-web-controller/HEAD/static/images/tc_progress_container_right.png
--------------------------------------------------------------------------------
/static/css/sonoshandbookpro-light-webfont.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jishi/node-sonos-web-controller/HEAD/static/css/sonoshandbookpro-light-webfont.woff
--------------------------------------------------------------------------------
/static/css/sonoshandbookpro-medium-webfont.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jishi/node-sonos-web-controller/HEAD/static/css/sonoshandbookpro-medium-webfont.woff
--------------------------------------------------------------------------------
/static/css/sonoshandbookpro-regular-webfont.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jishi/node-sonos-web-controller/HEAD/static/css/sonoshandbookpro-regular-webfont.woff
--------------------------------------------------------------------------------
/static/images/tc_progress_container_center.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jishi/node-sonos-web-controller/HEAD/static/images/tc_progress_container_center.png
--------------------------------------------------------------------------------
/static/images/tc_progress_equalizer_normal.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jishi/node-sonos-web-controller/HEAD/static/images/tc_progress_equalizer_normal.png
--------------------------------------------------------------------------------
/static/images/tc_progress_repeat_normal_off.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jishi/node-sonos-web-controller/HEAD/static/images/tc_progress_repeat_normal_off.png
--------------------------------------------------------------------------------
/static/images/tc_progress_repeat_normal_on.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jishi/node-sonos-web-controller/HEAD/static/images/tc_progress_repeat_normal_on.png
--------------------------------------------------------------------------------
/static/images/tc_progress_shuffle_normal_off.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jishi/node-sonos-web-controller/HEAD/static/images/tc_progress_shuffle_normal_off.png
--------------------------------------------------------------------------------
/static/images/tc_progress_shuffle_normal_on.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jishi/node-sonos-web-controller/HEAD/static/images/tc_progress_shuffle_normal_on.png
--------------------------------------------------------------------------------
/static/images/tc_progressbar_scrubber_normal.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jishi/node-sonos-web-controller/HEAD/static/images/tc_progressbar_scrubber_normal.png
--------------------------------------------------------------------------------
/static/images/tc_progress_crossfade_normal_off.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jishi/node-sonos-web-controller/HEAD/static/images/tc_progress_crossfade_normal_off.png
--------------------------------------------------------------------------------
/static/images/tc_progress_crossfade_normal_on.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jishi/node-sonos-web-controller/HEAD/static/images/tc_progress_crossfade_normal_on.png
--------------------------------------------------------------------------------
/static/images/tc_progressbar_scrubber_selected.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jishi/node-sonos-web-controller/HEAD/static/images/tc_progressbar_scrubber_selected.png
--------------------------------------------------------------------------------
/static/m/css/main.css:
--------------------------------------------------------------------------------
1 | @import url('common.css');
2 | @import url('landscape.css') (orientation:landscape);
3 | @import url('portrait.css') (orientation:portrait);
4 |
5 |
--------------------------------------------------------------------------------
/static/css/common.css:
--------------------------------------------------------------------------------
1 | body {
2 | background: #000000;
3 | color: #fff;
4 | font-family: 'sonoshandbookproregular', sans-serif;
5 | font-size: 15px;
6 | min-width: 800px;
7 | overflow: hidden;
8 | -moz-user-select: none;
9 | -webkit-user-select: none;
10 | height: 100vh;
11 | text-shadow: #111 0px -1px;
12 | }
--------------------------------------------------------------------------------
/static/js/sonos.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | var Sonos = {
4 | currentState: {
5 | selectedZone: null,
6 | zoneInfo: null
7 | },
8 | grouping: {},
9 | players: {},
10 | groupVolume: {
11 | disableUpdate: false,
12 | disableTimer: null
13 | },
14 | currentZoneCoordinator: function () {
15 | return Sonos.players[Sonos.currentState.selectedZone];
16 | }
17 | };
18 |
--------------------------------------------------------------------------------
/static/svg/trans_gradient_mask.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "sonos-web-controller",
3 | "version": "0.14.0",
4 | "description": "Web based controller for your Sonos system",
5 | "author": "Jimmy Shimizu ",
6 | "repository": {
7 | "type": "git",
8 | "url": "https://github.com/jishi/node-sonos-web-controller.git"
9 | },
10 | "dependencies": {
11 | "async": "^0.9.0",
12 | "node-static": "^0.7.9",
13 | "socket.io": "^2.2.0",
14 | "sonos-discovery": "https://github.com/jishi/node-sonos-discovery/archive/v1.7.3.tar.gz"
15 | },
16 | "engine": "node 4.x.x"
17 | }
18 |
--------------------------------------------------------------------------------
/static/m/js/socket-events.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | ///
3 | /// socket events
4 | ///
5 | Socket.topologyChanged = function () {
6 | GUI.updateCurrentStatus();
7 | GUI.renderVolumes();
8 | }
9 |
10 | Socket.transportStateChanged = function (player) {
11 | GUI.updateCurrentStatus();
12 | }
13 |
14 | Socket.groupVolumeChanged = function (data) {
15 | if (data.uuid == Sonos.currentState.selectedZone) {
16 | GUI.masterVolume.setVolume(data.groupState.volume);
17 | }
18 | for (var uuid in data.playerVolumes) {
19 | Sonos.players[data.uuid].state.volume = data.playerVolumes[uuid];
20 | //GUI.playerVolumes[uuid].setVolume(data.playerVolumes[uuid]);
21 | }
22 | }
--------------------------------------------------------------------------------
/static/css/font.css:
--------------------------------------------------------------------------------
1 | @font-face
2 | {
3 | font-family:sonos-logoregular;
4 | src: url(sonos-logo-regular-webfont.woff);
5 | font-weight:400;
6 | font-style:normal;
7 | }
8 |
9 | @font-face
10 | {
11 | font-family:sonoshandbookproregular;
12 | src: url(sonoshandbookpro-regular-webfont.woff);
13 | font-weight:400;
14 | font-style:normal;
15 | }
16 |
17 | @font-face
18 | {
19 | font-family:sonoshandbookpromedium;
20 | src: url(sonoshandbookpro-medium-webfont.woff);
21 | font-weight:400;
22 | font-style:normal;
23 | }
24 |
25 | @font-face
26 | {
27 | font-family:sonoshandbookprolight;
28 | src:url(sonoshandbookpro-light-webfont.woff);
29 | font-weight:400;
30 | font-style:normal;
31 | }
32 |
33 |
--------------------------------------------------------------------------------
/static/m/css/portrait.css:
--------------------------------------------------------------------------------
1 | body {
2 | font-size: 5vw;
3 | }
4 |
5 | #current-track-info {
6 | width: calc(100% - 8px);
7 | position: absolute;
8 | bottom: 0;
9 | padding-top: 1vh;
10 | background-image: linear-gradient(top, rgba(0,0,0,0) 0, rgba(0, 0, 0, 0.87) 5vh);
11 | background-image: -moz-linear-gradient(top, rgba(0,0,0,0) 0, rgba(0, 0, 0, 0.87) 5vh);
12 | background-image: -webkit-linear-gradient(top, rgba(0,0,0,0) 0, rgba(0, 0, 0, 0.87) 5vh);
13 | }
14 |
15 | #current-track-info .next-track {
16 | font-size: 4vw;
17 | }
18 |
19 | #current-track-art {
20 | width: 100%;
21 | }
22 |
23 | #controls img {
24 | width: 17vw;
25 | height: 17vw;
26 | border-radius: 8.5vw;
27 | }
28 |
29 | #master-volume .scrubber,
30 | .volume-bar .scrubber {
31 | width: 4vw;
32 | height: 4vw;
33 | border-radius: 2vw;
34 | background: #fff;
35 | }
36 |
37 | #master-volume .background,
38 | .volume-bar .background {
39 | top: 2vw;
40 | }
41 |
42 | #player-volumes h6 {
43 | margin: 1.5vh 1.5vh -2vh;
44 | font-size: 4vw;
45 | }
46 |
47 | #player-volumes-container {
48 | width: 99vw;
49 | }
50 |
51 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2013 Jimmy Shimizu
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of
6 | this software and associated documentation files (the "Software"), to deal in
7 | the Software without restriction, including without limitation the rights to
8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9 | the Software, and to permit persons to whom the Software is furnished to do so,
10 | subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
/static/spotify/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
10 | Spotify / Sonos Controller
11 |
12 |
13 |
14 |
17 |
21 |
37 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/static/m/css/landscape.css:
--------------------------------------------------------------------------------
1 | body {
2 | font-size: 3vw;
3 | }
4 |
5 | #current-track-info {
6 | width: calc(50% - 4px);
7 | height: calc(100% - 16px);
8 | position: absolute;
9 | right: 0;
10 | padding: 1vh;
11 | padding-left: 5vh;
12 | vertical-align: bottom;
13 | background-image: linear-gradient(left, rgba(0,0,0,0) 0, rgba(0, 0, 0, 0.87) 5vh);
14 | background-image: -moz-linear-gradient(left, rgba(0,0,0,0) 0, rgba(0, 0, 0, 0.87) 5vh);
15 | background-image: -webkit-linear-gradient(left, rgba(0,0,0,0) 0, rgba(0, 0, 0, 0.87) 5vh);
16 | }
17 |
18 | #current-track-info .next-track {
19 | font-size: 2vw;
20 | }
21 |
22 | #current-track-art {
23 | height: calc(100vh - 16px);
24 | float: left;
25 | }
26 |
27 | #controls {
28 | width: calc(100% - 20px);
29 | position: absolute;
30 | bottom: 0;
31 | }
32 |
33 | #controls img {
34 | width: 8vw;
35 | height: 8vw;
36 | transition: background 0.05s;
37 | border-radius: 4vw;
38 | }
39 |
40 | #master-volume .scrubber,
41 | .volume-bar .scrubber {
42 | width: 4vh;
43 | height: 4vh;
44 | border-radius: 2vh;
45 | background: #fff;
46 | }
47 | #master-volume .background,
48 | .volume-bar .background {
49 | top: 2vh;
50 | }
51 |
52 | #master-volume .tooltip,
53 | .volume-bar .tooltip {
54 | padding: 4px 6px 2px;
55 | font-size: 3vh;
56 | margin-top: -5vh;
57 | }
58 |
59 |
60 | #player-volumes h6 {
61 | font-size: 4vh;
62 | margin: 1.5vh 5vh -2vh;
63 | }
64 |
65 | #player-volumes-container {
66 | width: 49vw;
67 | margin-left: 50vw;
68 |
69 | }
70 |
71 |
72 |
--------------------------------------------------------------------------------
/static/m/js/gui-events.js:
--------------------------------------------------------------------------------
1 | document.getElementById('play-pause').addEventListener('click', function () {
2 |
3 | var action;
4 | // Find state of current player
5 | var player = Sonos.currentZoneCoordinator();
6 | if (player.state.playbackState == 'PLAYING' ) {
7 | action = 'pause';
8 | } else {
9 | action = 'play';
10 | }
11 |
12 | Socket.socket.emit('transport-state', { uuid: Sonos.currentState.selectedZone, state: action });
13 | });
14 |
15 | document.getElementById('next').addEventListener('click', function () {
16 | var action = "nextTrack";
17 | Socket.socket.emit('transport-state', { uuid: Sonos.currentState.selectedZone, state: action });
18 | });
19 | document.getElementById('prev').addEventListener('click', function () {
20 | var action = 'previousTrack';
21 | Socket.socket.emit('transport-state', { uuid: Sonos.currentState.selectedZone, state: action });
22 | });
23 |
24 | document.addEventListener('click', function (e) {
25 |
26 | var playerContainer = document.getElementById('player-volumes-container');
27 | // if click is inside this node, do nothing
28 | if (isChildOf(e.target, playerContainer)) {
29 | return;
30 | }
31 |
32 |
33 | if (playerContainer.classList.contains('show')) {
34 | playerContainer.classList.remove('show');
35 | } else if (isChildOf(e.target, document.getElementById('master-volume'))) {
36 | playerContainer.classList.add('show');
37 | }
38 | });
39 |
40 | function isChildOf(child, parent) {
41 | if (child == parent) return true;
42 | if (child.parentNode)
43 | return isChildOf(child.parentNode, parent);
44 |
45 | return false;
46 | }
47 |
48 |
49 |
--------------------------------------------------------------------------------
/static/js/gui.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | var GUI = {
3 | masterVolume: new VolumeSlider(document.getElementById('master-volume'), function (volume) {
4 | Socket.socket.emit('group-volume', {uuid: Sonos.currentState.selectedZone, volume: volume});
5 | }, function (obj) {
6 | // this logic controls show/hide of the individual volume controls
7 | var playerVolumesNode = document.getElementById('player-volumes');
8 | if (playerVolumesNode.classList.contains('hidden')) {
9 | playerVolumesNode.classList.remove('hidden');
10 | playerVolumesNode.classList.add('visible');
11 | document.addEventListener('click', function hideVolume(e) {
12 | // ignore the master volume
13 | if (e.target == obj) return;
14 | var playerVolumeContainer = document.getElementById('player-volumes');
15 | function isChildOf(child) {
16 | // ignore master volume elements
17 | if (child == obj) return true;
18 | // ignore player volume container
19 | if (child == playerVolumeContainer) return true;
20 | if (child == document) return false;
21 | return isChildOf(child.parentNode);
22 | }
23 | // and the playerVolume
24 | if (isChildOf(e.target)) return;
25 |
26 | // This is a random click, hide it and remove the container
27 | playerVolumesNode.classList.add('hidden');
28 | playerVolumesNode.classList.remove('visible');
29 | document.removeEventListener('click', hideVolume);
30 |
31 | });
32 | return false;
33 | }
34 | return true;
35 | }),
36 | playerVolumes: {},
37 | progress: new ProgressBar(document.getElementById('position-bar'), function (position) {
38 | // calculate new time
39 | var player = Sonos.currentZoneCoordinator();
40 | var desiredElapsed = Math.round(player.state.currentTrack.duration * position);
41 | player.state.elapsedTime = desiredElapsed;
42 | Socket.socket.emit('track-seek', {uuid: player.uuid, elapsed: desiredElapsed});
43 | })
44 | };
--------------------------------------------------------------------------------
/static/m/css/common.css:
--------------------------------------------------------------------------------
1 | html {
2 | -moz-box-sizing: border-box;
3 | -webkit-box-sizing: border-box;
4 | box-sizing: border-box;
5 | }
6 | body {
7 | background: #000000;
8 | color: #fff;
9 | -moz-box-sizing: border-box;
10 | -webkit-box-sizing: border-box;
11 | box-sizing: border-box;
12 | margin: 0;
13 | padding: 1vw;
14 | user-select: none;
15 | -moz-user-select: none;
16 | -webkit-user-select: none;
17 | font-family: 'sonoshandbookprolight', sans-serif;
18 | height: 100vh;
19 | overflow: hidden;
20 |
21 | }
22 |
23 | #zone-container,
24 | #music-sources-container {
25 | display: none;
26 | }
27 |
28 | #status-container {
29 | height: 100%;
30 | }
31 |
32 |
33 |
34 | #current-track-info p {
35 | margin: 0.5vw;
36 | }
37 |
38 | #current-track-info #track {
39 | font-weight: bold;
40 | }
41 |
42 | #current-track-info #artist,
43 | #current-track-info #album {
44 | color: #ccc;
45 | }
46 |
47 |
48 |
49 | #controls {
50 | text-align: center;
51 | }
52 |
53 | #controls img {
54 | transition: background 0.2s ease-out;
55 | }
56 |
57 | #controls img:active {
58 | background: #aaa;
59 | transition: background 0s;
60 | }
61 |
62 | #master-volume,
63 | #player-volumes .volume-bar {
64 | margin: 3vh 3vw;
65 | position: relative;
66 | }
67 |
68 | #master-volume .tooltip,
69 | .volume-bar .tooltip {
70 | position: absolute;
71 | margin-top: -4vh;
72 | padding: 4px 6px 2px;
73 | border-radius: 1vh;
74 | background: #333;
75 | }
76 |
77 | #master-volume .tooltip.hide,
78 | .volume-bar .tooltip.hide {
79 | display: none;
80 | }
81 |
82 | .volume-bar .background,
83 | #master-volume .background {
84 | background: #fff;
85 | height: 1px;
86 | width: calc(100% - 12px);
87 | position: absolute;
88 | }
89 |
90 | #player-volumes-container {
91 | transition: max-height .3s ease-out;
92 | -moz-box-sizing: border-box;
93 | -webkit-box-sizing: border-box;
94 | box-sizing: border-box;
95 | background: #000;
96 | opacity: .8;
97 | max-height: 0;
98 | position: absolute;
99 | bottom: 10vh;
100 | overflow: hidden;
101 | -webkit-transform: translateZ(0);
102 | }
103 |
104 | #player-volumes-container.show {
105 | max-height: 100%;
106 | }
107 |
108 |
--------------------------------------------------------------------------------
/static/m/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
9 | Sonos Web Controller
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
21 |
22 |
26 |
27 |
28 |
![]()
29 |
30 |
31 |
36 |
Next:
37 |
50 |
51 |
52 |
53 |
54 |
asdf
55 |
asdf
56 |
asdf
57 |
asdf
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
--------------------------------------------------------------------------------
/static/js/socket.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | ///
3 | /// socket events
4 | ///
5 |
6 | var Socket = Socket || {};
7 |
8 | var target = location.origin;
9 | Socket.socket = io.connect(target);
10 |
11 | Socket.socket.on('topology-change', function (data) {
12 | Sonos.grouping = {};
13 | var stateTime = new Date().valueOf();
14 | var shouldRenderVolumes = false;
15 | data.forEach(function (player) {
16 | player.stateTime = stateTime;
17 | Sonos.players[player.uuid] = player;
18 | if (!Sonos.grouping[player.coordinator]) Sonos.grouping[player.coordinator] = [];
19 | Sonos.grouping[player.coordinator].push(player.uuid);
20 | });
21 |
22 | // If the selected group dissappeared, select a new one.
23 | if (!Sonos.grouping[Sonos.currentState.selectedZone]) {
24 | // just get first zone available
25 | for (var uuid in Sonos.grouping) {
26 | Sonos.currentState.selectedZone = uuid;
27 | break;
28 | }
29 | // we need queue as well!
30 | Socket.socket.emit('queue', {uuid:Sonos.currentState.selectedZone});
31 | shouldRenderVolumes = true;
32 | }
33 |
34 | if (Socket.topologyChanged instanceof Function) Socket.topologyChanged(shouldRenderVolumes);
35 | });
36 |
37 | Socket.socket.on('transport-state', function (player) {
38 | player.stateTime = new Date().valueOf();
39 | Sonos.players[player.uuid] = player;
40 |
41 | if (Socket.transportStateChanged instanceof Function) Socket.transportStateChanged(player);
42 |
43 | });
44 |
45 | Socket.socket.on('group-volume', function (data) {
46 | if (Socket.groupVolumeChanged instanceof Function) Socket.groupVolumeChanged(data);
47 | });
48 |
49 | Socket.socket.on('volume', function (data) {
50 | if (Socket.volumeChanged instanceof Function) Socket.volumeChanged(data);
51 | });
52 |
53 | Socket.socket.on('group-mute', function (data) {
54 | Sonos.players[data.uuid].groupState.mute = data.newMute;
55 | if (Socket.groupMuteChanged instanceof Function) Socket.groupMuteChanged(data);
56 | });
57 |
58 | Socket.socket.on('mute', function (data) {
59 | if (Socket.muteChanged instanceof Function) Socket.muteChanged(data);
60 | });
61 |
62 | Socket.socket.on('favorites', function (data) {
63 | if (Socket.favoritesChanged instanceof Function) Socket.favoritesChanged(data);
64 | });
65 |
66 | Socket.socket.on('queue', function (data) {
67 | if (Socket.queueChanged instanceof Function) Socket.queueChanged(data);
68 | });
69 |
70 |
71 | Socket.socket.on('search-result', function (data) {
72 | if (Socket.searchResultReceived instanceof Function) Socket.searchResultReceived(data);
73 | })
74 |
--------------------------------------------------------------------------------
/static/spotify/css/spotify.css:
--------------------------------------------------------------------------------
1 | * {
2 | -moz-box-sizing: border-box;
3 | -webkit-box-sizing: border-box;
4 | box-sizing: border-box;
5 | }
6 |
7 | body {
8 | margin: 0;
9 | }
10 |
11 | body header {
12 | background: linear-gradient(to bottom, rgb(128,128,128), rgb(72,72,72));
13 | padding: 5px;
14 | }
15 |
16 | #search-field {
17 | border-radius: 11px;
18 | border: 1px solid rgb(30,30,30);
19 | padding: 2px 10px;
20 | outline: none;
21 |
22 | }
23 |
24 | #control {
25 | position: absolute;
26 | width: 100%;
27 | bottom: 0;
28 | display: flex;
29 | flex-direction: row;
30 | background: linear-gradient(to bottom, rgb(96,96,96), rgb(78,78,78));
31 | border-top: rgb(120,120,120) solid 1px;
32 | border-top: rgb(70,70,70) solid 1px;
33 | padding: 4px;
34 | }
35 |
36 | #control #play-pause,
37 | #control #prev,
38 | #control #next {
39 | border-radius: 17px;
40 | width: 34px;
41 | height: 36px;
42 | border-top: 2px solid rgb(230,230,230);
43 | border-bottom: 2px solid rgb(100,100,100);
44 | background: linear-gradient(to bottom, rgb(217,217,217), rgb(133,133,133));
45 | -moz-box-sizing: border-box;
46 | -webkit-box-sizing: border-box;
47 | vertical-align: middle;
48 | box-shadow: 0 0 1px rgb(70,70,70);
49 | }
50 |
51 | #control #prev,
52 | #control #next {
53 | width: 25px;
54 | height: 25px;
55 | }
56 |
57 | #control #controls {
58 | flex: 0 0 120px;
59 | }
60 |
61 | #control #master-volume {
62 | flex: 0 0 120px;
63 | margin: auto 10px;
64 | height: 12px;
65 | background: linear-gradient(to bottom, rgb(69,69,69), rgb(43,43,43));
66 | border-radius: 6px;
67 | border-top: 1px solid rgb(33,33,33);
68 | border-bottom: 1px solid rgb(99,99,99);
69 | vertical-align: middle;
70 | }
71 |
72 | #control #master-volume .scrubber,
73 | #control #position-bar .scrubber {
74 | width: 10px;
75 | height: 10px;
76 | border-radius: 5px;
77 | background: linear-gradient(to bottom, rgb(240,240,240), rgb(72,72,72));
78 |
79 | }
80 |
81 | #control #player-volumes-container {
82 | position: absolute;
83 | }
84 |
85 | #control #countup,
86 | #control #countdown {
87 | flex: 0 0 50px;
88 | margin: auto 0;
89 | text-align: right;
90 | }
91 |
92 | #control #countdown {
93 | text-align: left;
94 | }
95 |
96 | #control #position-bar {
97 | flex: 1;
98 | margin: auto 10px;
99 | height: 12px;
100 | background: linear-gradient(to bottom, rgb(69,69,69), rgb(43,43,43));
101 | border-radius: 6px;
102 | border-top: 1px solid rgb(33,33,33);
103 | border-bottom: 1px solid rgb(99,99,99);
104 | vertical-align: middle;
105 | }
106 |
107 |
108 |
--------------------------------------------------------------------------------
/static/svg/pause.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
85 |
--------------------------------------------------------------------------------
/static/svg/play.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
83 |
--------------------------------------------------------------------------------
/static/svg/mute_on.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
90 |
--------------------------------------------------------------------------------
/static/dashboard/dashboard.css:
--------------------------------------------------------------------------------
1 | html {}
2 | h1,h2,h3,h4,h5,h6,p {
3 | padding: 0;
4 | margin: 0;
5 | }
6 | * {
7 | -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box;
8 | }
9 | body {
10 | margin: 0;
11 | padding: 0;
12 | height: 100vh;
13 | background: #000;
14 | font-size: 23px;
15 | font-family: 'Roboto', sans-serif;
16 | font-weight: 300;
17 | padding-top: 20px;
18 | overflow: hidden;
19 | letter-spacing: 0.01em;
20 | }
21 |
22 | h1,h2,h3,h4,h5,h6 {
23 | color: rgb(4,90,218);
24 | }
25 |
26 | body #main {
27 | margin: 0;
28 | padding: 0;
29 | height: 100%;
30 | color: #fff;
31 | position: relative;
32 | }
33 |
34 | .track-info {
35 | width: 50vw;
36 | position: absolute;
37 | left: calc(50% - 25%);
38 | transition: left 1s, opacity 1s;
39 | }
40 |
41 | #next-track-info {
42 | left: calc(50% - 25% + 52%);
43 | opacity: 0.5;
44 | }
45 |
46 | #prev-track-info {
47 | left: calc(50% - 25% - 52%);
48 | opacity: 0.5;
49 | }
50 |
51 | #dead-track-info {
52 | left: calc(50% - 25% - 102%);
53 |
54 | }
55 |
56 | #upcoming-track-info {
57 | left: calc(50% - 25% + 102%);
58 | opacity: 0;
59 |
60 | }
61 |
62 | .track-info img {
63 | display: block;
64 | width: 100%;
65 | vertical-align: top;
66 | }
67 |
68 | .track-info div {
69 | margin-top: 10px;
70 | text-align: left;
71 | width: 100%;
72 | overflow: hidden;
73 | white-space: nowrap;
74 | text-overflow: ellipsis;
75 | }
76 |
77 |
78 |
79 | .track-info div .track {
80 | font-weight: 700;
81 | }
82 |
83 |
84 |
85 | @font-face {
86 | font-family: 'BrownRegular';
87 | src: url('fonts/lineto-brown-regular.eot');
88 | src: url('fonts/lineto-brown-regular.eot?#iefix') format('embedded-opentype'),
89 | url('fonts/lineto-brown-regular.woff') format('woff'),
90 | url('fonts/lineto-brown-regular.ttf') format('truetype'),
91 | url('fonts/lineto-brown-regular.svg#BrownRegular') format('svg');
92 | font-weight: normal;
93 | font-style: normal;
94 | }
95 |
96 | @font-face {
97 | font-family: 'BrownItalic';
98 | src: url('fonts/lineto-brown-italic.eot');
99 | src: url('fonts/lineto-brown-italic.eot?#iefix') format('embedded-opentype'),
100 | url('fonts/lineto-brown-italic.woff') format('woff'),
101 | url('fonts/lineto-brown-italic.ttf') format('truetype'),
102 | url('fonts/lineto-brown-italic.svg#BrownItalic') format('svg');
103 | font-weight: normal;
104 | font-style: normal;
105 | }
106 |
107 | @font-face {
108 | font-family: 'BrownBold';
109 | src: url('fonts/lineto-brown-bold.eot');
110 | src: url('fonts/lineto-brown-bold.eot?#iefix') format('embedded-opentype'),
111 | url('fonts/lineto-brown-bold.woff') format('woff'),
112 | url('fonts/lineto-brown-bold.ttf') format('truetype'),
113 | url('fonts/lineto-brown-bold.svg#BrownBold') format('svg');
114 | font-weight: normal;
115 | font-style: normal;
116 | }
117 |
118 | @font-face {
119 | font-family: 'BrownLight';
120 | src: url('fonts/Brown-Light.otf');
121 | font-weight: normal;
122 | font-style: normal;
123 | }
--------------------------------------------------------------------------------
/static/svg/mute_off.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
93 |
--------------------------------------------------------------------------------
/static/js/volume-slider.js:
--------------------------------------------------------------------------------
1 | function VolumeSlider(containerObj, callback, clickCallback) {
2 | var state = {
3 | cursorX: 0,
4 | originalX: 0,
5 | maxX: 0,
6 | currentX: 0,
7 | slider: null,
8 | volume: 0,
9 | disableUpdate: false,
10 | disableTimer: null
11 | };
12 |
13 | function setVolume(volume) {
14 | // calculate a pixel offset based on percentage
15 | if (state.volume == volume) return;
16 | setScrubberPosition(volume);
17 | if (typeof callback == "function")
18 | callback(volume);
19 | }
20 |
21 | function setScrubberPosition(volume) {
22 | var offset = Math.round(state.maxX * volume / 100);
23 | state.currentX = offset;
24 | state.slider.style.marginLeft = offset + 'px';
25 | state.volume = volume;
26 | }
27 |
28 | function handleVolumeWheel(e) {
29 | var newVolume;
30 | if(e.deltaY > 0) {
31 | // volume down
32 | newVolume = state.volume - 2;
33 | } else {
34 | // volume up
35 | newVolume = state.volume + 2;
36 | }
37 | if (newVolume < 0) newVolume = 0;
38 | if (newVolume > 100) newVolume = 100;
39 |
40 | setVolume( newVolume );
41 | clearTimeout(state.disableTimer);
42 | state.disableUpdate = true;
43 | state.disableTimer = setTimeout(function () {state.disableUpdate = false}, 800);
44 |
45 | //socket.emit('group-volume', {uuid: Sonos.currentState.selectedZone, volume: newVolume});
46 | //newVolume = Sonos.currentZoneCoordinator().groupState.volume = newVolume;
47 |
48 |
49 | }
50 |
51 | function handleClick(e) {
52 | // Be able to cancel this from a callback if necessary
53 | if (typeof clickCallback == "function" && clickCallback(this) == false) return;
54 |
55 | if (e.target.tagName == "IMG") return;
56 |
57 | var newVolume;
58 | if(e.layerX < state.currentX) {
59 | // volume down
60 | newVolume = state.volume - 2;
61 | } else {
62 | // volume up
63 | newVolume = state.volume + 2;
64 | }
65 |
66 | if (newVolume < 0) newVolume = 0;
67 | if (newVolume > 100) newVolume = 100;
68 |
69 | setVolume(newVolume);
70 | clearTimeout(state.disableTimer);
71 | state.disableUpdate = true;
72 | state.disableTimer = setTimeout(function () {state.disableUpdate = false}, 800);
73 | }
74 |
75 | function onDrag(e) {
76 | var deltaX = e.clientX - state.cursorX;
77 | var nextX = state.originalX + deltaX;
78 |
79 | if ( nextX > state.maxX ) nextX = state.maxX;
80 | else if ( nextX < 1) nextX = 1;
81 |
82 | // calculate percentage
83 | var volume = Math.floor(nextX / state.maxX * 100);
84 | setVolume(volume);
85 | }
86 |
87 | var sliderWidth = containerObj.clientWidth;
88 | state.maxX = sliderWidth - 21;
89 | state.slider = containerObj.querySelector('img');
90 |
91 | state.slider.addEventListener('mousedown', function (e) {
92 | state.cursorX = e.clientX;
93 | state.originalX = state.currentX;
94 | clearTimeout(state.disableTimer);
95 | state.disableUpdate = true;
96 | document.addEventListener('mousemove', onDrag);
97 | e.preventDefault();
98 | });
99 |
100 | document.addEventListener('mouseup', function () {
101 | document.removeEventListener('mousemove', onDrag);
102 | state.currentX = state.slider.offsetLeft;
103 | state.disableTimer = setTimeout(function () { state.disableUpdate = false }, 800);
104 | });
105 |
106 | // Since Chrome 31 wheel event is also supported
107 | containerObj.addEventListener("wheel", handleVolumeWheel);
108 |
109 | // For click-to-adjust
110 | containerObj.addEventListener("click", handleClick);
111 |
112 |
113 |
114 | // Add some functions to go
115 | this.setVolume = function (volume) {
116 | if (state.disableUpdate) return;
117 | setScrubberPosition(volume);
118 | }
119 |
120 | return this;
121 | }
--------------------------------------------------------------------------------
/static/svg/prev.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
109 |
--------------------------------------------------------------------------------
/static/svg/next.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
110 |
--------------------------------------------------------------------------------
/static/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
11 | Sonos Web Controller
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
22 |
23 |

24 |
25 |

26 |

27 |
28 |

29 |
30 |
31 |
32 |

33 |

34 |
35 |

36 |

37 |

38 |
0:00
39 |
46 |
-0:00
47 |
48 |
49 |
60 |
61 |
62 |
66 |
67 |
68 |
NOW PLAYING
69 |
70 |
71 |
72 |
![]()
73 |
74 |
75 |
76 |
Track
77 |
78 |
Artist
79 |
80 |
Album
81 |
82 |
83 |
Next
84 |
85 |
86 |
87 |
![]()
88 |
89 |
Station
90 |
91 |
Information
92 |
93 |
94 |
95 |
96 |
QUEUE
97 |
99 |
100 |
101 |
FAVORITES
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
--------------------------------------------------------------------------------
/static/dashboard/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
10 | Sonos Dashboard
11 |
12 |
13 |
14 |
15 |
16 |
![]()
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
![]()
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
![]()
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
126 |
127 |
128 |
--------------------------------------------------------------------------------
/static/m/js/touch-volume.js:
--------------------------------------------------------------------------------
1 | function TouchVolumeSlider(containerObj, callback) {
2 | var state = {
3 | cursorX: 0,
4 | originalX: 0,
5 | maxX: 0,
6 | currentX: 0,
7 | slider: null,
8 | tooltip: null,
9 | volume: 0,
10 | disableUpdate: false,
11 | numberOfTouches: 0,
12 | disableTimer: null
13 | };
14 |
15 | function setVolume(volume) {
16 | // calculate a pixel offset based on percentage
17 | if (state.volume == volume) return;
18 | setScrubberPosition(volume);
19 | if (typeof callback == "function")
20 | callback(volume);
21 | }
22 |
23 | function setScrubberPosition(volume) {
24 | var offset = Math.round(state.maxX * volume * 0.01);
25 | state.currentX = offset;
26 | //state.slider.style.marginLeft = offset + 'px';
27 | state.slider.style.transform = 'translateX(' + offset + 'px)';
28 | // For chrome 35 or less
29 | state.slider.style.webkitTransform = 'translateX(' + offset + 'px)';
30 | state.tooltip.textContent = volume;
31 | // calculate tooltip offset
32 | var adjustedOffset = offset + (state.slider.clientWidth - state.tooltip.clientWidth) * 0.5;
33 | state.tooltip.style.transform = 'translateX(' + adjustedOffset + 'px)';
34 | // For chrome 35 or less
35 | state.tooltip.style.webkitTransform = 'translateX(' + adjustedOffset + 'px)';
36 | state.volume = volume;
37 | }
38 |
39 | function handleVolumeWheel(e) {
40 | var newVolume;
41 | if(e.deltaY > 0) {
42 | // volume down
43 | newVolume = state.volume - 2;
44 | } else {
45 | // volume up
46 | newVolume = state.volume + 2;
47 | }
48 | if (newVolume < 0) newVolume = 0;
49 | if (newVolume > 100) newVolume = 100;
50 |
51 | setVolume( newVolume );
52 | clearTimeout(state.disableTimer);
53 | state.disableUpdate = true;
54 | state.disableTimer = setTimeout(function () {state.disableUpdate = false}, 800);
55 |
56 | //socket.emit('group-volume', {uuid: Sonos.currentState.selectedZone, volume: newVolume});
57 | //newVolume = Sonos.currentZoneCoordinator().groupState.volume = newVolume;
58 |
59 |
60 | }
61 |
62 | function handleClick(e) {
63 | // Be able to cancel this from a callback if necessary
64 | if (typeof clickCallback == "function" && clickCallback(this) == false) return;
65 |
66 | if (e.target.tagName == "IMG") return;
67 |
68 | var newVolume;
69 | if(e.layerX < state.currentX) {
70 | // volume down
71 | newVolume = state.volume - 2;
72 | } else {
73 | // volume up
74 | newVolume = state.volume + 2;
75 | }
76 |
77 | if (newVolume < 0) newVolume = 0;
78 | if (newVolume > 100) newVolume = 100;
79 |
80 | setVolume(newVolume);
81 | clearTimeout(state.disableTimer);
82 | state.disableUpdate = true;
83 | state.disableTimer = setTimeout(function () {state.disableUpdate = false}, 800);
84 | }
85 |
86 | function onDrag(multi) {
87 | var e = multi.touches[0];
88 | var deltaX = (e.clientX - state.cursorX) * 0.25;
89 | var nextX = state.originalX + deltaX;
90 |
91 | if ( nextX > state.maxX ) nextX = state.maxX;
92 | else if ( nextX < 1) nextX = 1;
93 |
94 | // calculate percentage
95 | var volume = Math.floor(nextX / state.maxX * 100);
96 | setVolume(volume);
97 | multi.preventDefault();
98 | }
99 |
100 |
101 | var sliderWidth = containerObj.clientWidth;
102 | state.maxX = sliderWidth - 21;
103 | state.slider = containerObj.querySelector('.scrubber');
104 | state.tooltip = containerObj.querySelector('.tooltip');
105 |
106 | containerObj.addEventListener('touchstart', function (multi) {
107 | state.numberOfTouches++;
108 | var e = multi.touches[0];
109 | state.cursorX = e.clientX;
110 | state.originalX = state.currentX;
111 | clearTimeout(state.disableTimer);
112 | state.disableUpdate = true;
113 | state.tooltip.classList.remove("hide");
114 | document.addEventListener('touchmove', onDrag);
115 | //multi.preventDefault();
116 | });
117 |
118 | document.addEventListener('touchend', function () {
119 | state.numberOfTouches--;
120 | if (state.numberOfTouches > 0) return;
121 | state.tooltip.classList.add("hide");
122 | document.removeEventListener('touchmove', onDrag);
123 | state.disableTimer = setTimeout(function () { state.disableUpdate = false }, 800);
124 | });
125 |
126 | // Since Chrome 31 wheel event is also supported
127 | //containerObj.addEventListener("wheel", handleVolumeWheel);
128 |
129 | // For click-to-adjust
130 | //containerObj.addEventListener("click", handleClick);
131 |
132 |
133 |
134 | // Add some functions to go
135 | this.setVolume = function (volume) {
136 | if (state.disableUpdate) return;
137 |
138 | // To make a successful adjust of the tooltip, we need to show it temporarily.
139 | state.tooltip.classList.remove("hide");
140 | setScrubberPosition(volume);
141 | state.tooltip.classList.add("hide");
142 |
143 |
144 | }
145 |
146 | return this;
147 | }
--------------------------------------------------------------------------------
/static/m/js/gui.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | var GUI = {
3 | masterVolume: new TouchVolumeSlider(document.getElementById('master-volume'), function (volume) {
4 | Socket.socket.emit('group-volume', {uuid: Sonos.currentState.selectedZone, volume: volume});
5 | }),
6 | playerVolumes: {},
7 | updateCurrentStatus: function () {
8 | var selectedZone = Sonos.currentZoneCoordinator();
9 |
10 | // Try and fetch high-res coverart.
11 | var currentTrackArt = document.getElementById("current-track-art");
12 | currentTrackArt.src = selectedZone.state.currentTrack.absoluteAlbumArtUri;
13 |
14 | //document.getElementById('page-title').textContent = selectedZone.state.currentTrack.title + ' - Sonos Web Controller';
15 | document.getElementById("track").textContent = selectedZone.state.currentTrack.title;
16 | document.getElementById("artist").textContent = selectedZone.state.currentTrack.artist;
17 | document.getElementById("album").textContent = selectedZone.state.currentTrack.album;
18 |
19 | if (selectedZone.state.nextTrack) {
20 | var nextTrack = selectedZone.state.nextTrack;
21 | document.getElementById("next-track").textContent = nextTrack.title + " - " + nextTrack.artist;
22 | }
23 |
24 | var state = selectedZone.state.playbackState;
25 | var playPauseButton = document.getElementById('play-pause');
26 |
27 | if (state == 'PLAYING') {
28 | playPauseButton.src = '../svg/pause.svg';
29 | } else if (state === 'PAUSED_PLAYBACK') {
30 | playPauseButton.src = '../svg/play.svg';
31 | }
32 |
33 | GUI.masterVolume.setVolume(selectedZone.groupState.volume);
34 |
35 | //var repeat = document.getElementById("repeat");
36 | //if (selectedZone.playMode.repeat) {
37 | // repeat.src = repeat.src.replace(/_off\.png/, "_on.png");
38 | //} else {
39 | // repeat.src = repeat.src.replace(/_on\.png/, "_off.png");
40 | //}
41 | //
42 | // //var shuffle = document.getElementById("shuffle");
43 | // //if (selectedZone.playMode.shuffle) {
44 | // // shuffle.src = shuffle.src.replace(/_off\.png/, "_on.png");
45 | // //} else {
46 | // // shuffle.src = shuffle.src.replace(/_on\.png/, "_off.png");
47 | // //}
48 | //
49 | // //var crossfade = document.getElementById("crossfade");
50 | // //if (selectedZone.playMode.crossfade) {
51 | // // crossfade.src = crossfade.src.replace(/_off\.png/, "_on.png");
52 | // //} else {
53 | // // crossfade.src = crossfade.src.replace(/_on\.png/, "_off.png");
54 | //}
55 |
56 |
57 | // GUI.progress.update(selectedZone);
58 | },
59 | renderVolumes: function () {
60 | var oldWrapper = document.getElementById('player-volumes');
61 | var newWrapper = oldWrapper.cloneNode(false);
62 | var masterVolume = document.getElementById('master-volume');
63 | //var masterMute = document.getElementById('master-mute');
64 |
65 | var playerNodes = [];
66 |
67 | for (var i in Sonos.players) {
68 | var player = Sonos.players[i];
69 | var playerVolumeBar = masterVolume.cloneNode(true);
70 | var playerVolumeBarContainer = document.createElement('div');
71 | playerVolumeBarContainer.id = "volume-" + player.uuid;
72 | playerVolumeBar.id = "";
73 | playerVolumeBar.dataset.uuid = player.uuid;
74 | var playerName = document.createElement('h6');
75 | //var playerMute = masterMute.cloneNode(true);
76 | //playerMute.id = "mute-" + player.uuid;
77 | //playerMute.className = "mute-button";
78 | //playerMute.src = player.state.mute ? "/svg/mute_on.svg" : "/svg/mute_off.svg";
79 | //playerMute.dataset.id = player.uuid;
80 | playerName.textContent = player.roomName;
81 | playerVolumeBarContainer.appendChild(playerName);
82 | //playerVolumeBarContainer.appendChild(playerMute);
83 | playerVolumeBarContainer.appendChild(playerVolumeBar);
84 | newWrapper.appendChild(playerVolumeBarContainer);
85 | playerNodes.push({uuid: player.uuid, node: playerVolumeBar});
86 | }
87 |
88 | oldWrapper.parentNode.replaceChild(newWrapper, oldWrapper);
89 |
90 | // They need to be part of DOM before initialization
91 | playerNodes.forEach(function (playerPair) {
92 | var uuid = playerPair.uuid;
93 | var node = playerPair.node;
94 | GUI.playerVolumes[uuid] = new TouchVolumeSlider(node, function (vol) {
95 | Socket.socket.emit('volume', {uuid: uuid, volume: vol});
96 | });
97 |
98 | GUI.playerVolumes[uuid].setVolume(Sonos.players[uuid].state.volume);
99 | });
100 | }
101 | };
102 |
103 | var HTTP = {
104 | request: function (url, callback) {
105 | var httpRequest = new XMLHttpRequest();
106 | httpRequest.onreadystatechange = function () {
107 | if (httpRequest.readyState !== 4) return;
108 | if (httpRequest.status === 200) {
109 | // success
110 | var responseJSON = JSON.parse(httpRequest.responseText);
111 | callback(responseJSON);
112 | return;
113 | }
114 |
115 | throw "Error";
116 | }
117 | httpRequest.open("GET", url);
118 | httpRequest.send(null);
119 | }
120 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Sonos Web Controller
2 | ====================
3 |
4 | NOTE! THIS IS A WORK IN PROGRESS! This is a really early alpha. Things might break from time to time, until I settle a final release that I consider stable for daily use. Until then only master branch will exist.
5 |
6 | NOTE! **Requires node.js 4.0.0+**
7 |
8 | As a substitute for the perl-based controller (www.purple.org) this project is aimed at giving similar controller as the native one, but in a browser.
9 |
10 | Using node.js as backend, that will keep track of the state of the players, and WebSockets (socket.io) for a native feel (where the state of the players will be updated as soon as it changes).
11 |
12 | Main focus will be to support the following:
13 |
14 | * Zone management
15 | * Volume control
16 | * Transport control (play/pause, rwd, fwd, seek)
17 | * Queue listing
18 | * Browsing favorites
19 |
20 | Main target is to be able to run this on a raspberry pi, but any node.js compatible platform should work. I will only focus on supporting the following browsers:
21 |
22 | * Chrome latest version (31 as of today)
23 | * Firefox latest version (25 as of today)
24 |
25 | For a screenshot of current progress, see: http://upload.grabbarna.se/files/sonos-web-controller-201412.png
26 | For a video demo: http://www.youtube.com/watch?v=_7Dke5LsTF0
27 |
28 | Currently, prev, next, play/pause and the progress bar works. Volume (player and group) work as well. Track info, player state and progress bar updates instantly when controlled from another device, which was one of the main goals with this project. You can group and ungroup via drag n' drop, and you can change music source using your favorites (no browsing atm). Also, different playmode options can be toggled: repeat, random, crossfade.
29 |
30 | settings.json
31 | =============
32 |
33 | To persist settings between updates, you can create a file called settings.json in the root folder (same level as server.js). Today this can take two arguments:
34 |
35 | {
36 | "port": 8080,
37 | "cacheDir": "./cache"
38 | }
39 |
40 | The above are the defaults. Change them as you like and it will take precedence over the default ones.
41 |
42 | Installation
43 | ============
44 |
45 | Easiest way to install this today would be to use git. Make sure and install node.js and npm (node package manager) for your platform. Make sure that the node and npm command works. If not, you will fail miserably. Install git for your platform. Make sure the git command works as well.
46 |
47 | Create a folder on your computer where you want the files to reside (for example, c:\node\sonos-web-controller or /opt/node/sonos-web-controller). Now, using a command prompt/terminal, stand in that directory, and do the following:
48 |
49 | git clone https://github.com/jishi/node-sonos-web-controller.git .
50 | npm install
51 | node server.js
52 |
53 | Now, visit http://localhost:8080.
54 |
55 | For running this as a service under linux, I suggest using pm2 (https://github.com/Unitech/pm2). You must use fork mode (-x) otherwise it will use 100% CPU (cluster mode is the default, if you want to switch you need to delete the old app from pm2 with "pm2 delete appname"). For windows, you may try Winser (http://jfromaniello.github.io/winser/), but haven't tested it.
56 |
57 |
58 |
59 | This software is in no way affiliated nor endorsed by Sonos inc.
60 |
61 | Change log
62 | ==========
63 | * 0.14.0 Upgraded to use sonos-discovery v1.1.2
64 | * 0.13.0 Merged mobile branch with simplified mobile view (/m)
65 | * 0.12.1 Attempted to fix Safari rendering bug for play/pause icon
66 | * 0.12.0 Support household filtering. Requires sonos-discovery 0.12.0 or higher
67 | * 0.7.0 Refactoring attempt
68 | * 0.6.2 Now handles startup from different working directory (like, node /opt/sonos-web-server/server.js)
69 | * 0.6.1 Fixed the mute state problem
70 | * 0.6.0 Progressbar, mouse wheel and incremental click. Styled scrollbars (Chrome only). Fixed wonky player volumes (when dragging)
71 | * 0.5.7 Working progressbar (drag and slide). Working player mute. Requires sonos-discovery 0.8.1
72 | * 0.5.6 Working master mute. Some minor UI improvements
73 | * 0.5.3 Handle switching back to queue from radio stream
74 | * 0.5.2 Supports settings.json for customization. Port and cacheDir to start with.
75 | * 0.5.0 Working player volume controls. Removed mute for the time being. Working shuffle/repeat/crossfade. requires sonos-discovery 0.7.x
76 | * 0.4.4 use md5 hash for cached image instead (fixes ENAMETOOLONG probably)
77 | * 0.4.2 Added dynamic favicon and title. Make sure to use sonos-discovery 0.6.1 or later to fix albumArtURI error
78 | * 0.4.1 Added cover art for now playing
79 | * 0.4.0 Now has working queue listing. Will tweak it for better performance later
80 | * 0.3.0 Lists favorites and possibility to replace queue with favorite/radio
81 | * 0.2.0 Now working group management (drag n' drop style)
82 | * 0.1.5 Master volume control now handles click for small increments
83 | * 0.1.4 Working master volume control (requires upgraded sonos-discovery 0.5.2)
84 |
--------------------------------------------------------------------------------
/static/js/progress-bar.js:
--------------------------------------------------------------------------------
1 | function ProgressBar(containerObj, callback) {
2 | var state = {
3 | cursorX: 0,
4 | originalX: 0,
5 | maxX: 0,
6 | currentX: 0,
7 | slider: null,
8 | progress: 0,
9 | slideInProgress: false,
10 | elapsed: 0,
11 | duration: 0,
12 | lastUpdate: 0,
13 | zoneState: "STOPPED",
14 | hasBeenDragged: false
15 | };
16 |
17 | var progressAdjustTimer, tickerInterval;
18 |
19 | // Update position
20 | this.setPosition = function (position) {
21 | if (state.slideInProgress) return;
22 | setPosition(position);
23 | }
24 |
25 | this.update = function (selectedZone) {
26 | state.elapsed = selectedZone.state.elapsedTime;
27 | state.duration = selectedZone.state.currentTrack.duration;
28 | state.lastUpdate = selectedZone.stateTime;
29 | state.zoneState = selectedZone.state.playbackState;
30 |
31 | clearInterval(tickerInterval);
32 |
33 | if (state.zoneState == "PLAYING")
34 | tickerInterval = setInterval(updatePosition, 500);
35 |
36 | updatePosition();
37 | }
38 |
39 | function updatePosition(force) {
40 | if (state.slideInProgress && !force) return;
41 | var elapsedMillis, realElapsed;
42 |
43 | if (state.zoneState == "PLAYING") {
44 | elapsedMillis = state.elapsed * 1000 + (Date.now() - state.lastUpdate);
45 | realElapsed = Math.floor(elapsedMillis / 1000);
46 | } else {
47 | realElapsed = state.elapsed;
48 | elapsedMillis = realElapsed * 1000;
49 | }
50 |
51 | document.getElementById("countup").textContent = toFormattedTime(realElapsed);
52 | var remaining = state.duration - realElapsed;
53 | document.getElementById("countdown").textContent = "-" + toFormattedTime(remaining);
54 | var position = elapsedMillis / (state.duration * 1000);
55 | setPosition(position);
56 | }
57 |
58 | function setPosition(position) {
59 | // calculate offset
60 | var offset = Math.round(state.maxX * position);
61 | state.slider.style.marginLeft = offset + "px";
62 | state.currentX = offset;
63 | state.progress = position;
64 | }
65 |
66 | function handleMouseWheel(e) {
67 | var newProgress;
68 | state.elapsed = state.elapsed + (Date.now() - state.lastUpdate) / 1000;
69 | state.lastUpdate = Date.now();
70 |
71 | if (e.deltaY < 0) {
72 | // wheel down
73 | state.elapsed += 2;
74 | } else {
75 | // wheel up
76 | state.elapsed -= 2;
77 | }
78 |
79 | state.slideInProgress = true;
80 | setPosition(state.elapsed / state.duration);
81 | updatePosition(true);
82 | clearTimeout(progressAdjustTimer);
83 | progressAdjustTimer = setTimeout(function () {
84 | callback(state.elapsed / state.duration);
85 | state.slideInProgress = false
86 | }, 800);
87 |
88 | }
89 |
90 | function isWithinElement(target, container) {
91 | if (target == container) return true;
92 | if (target == document) return false;
93 | return isWithinElement(target.parentNode, container);
94 | }
95 |
96 | function handleClick(e) {
97 | if (isWithinElement(e.target, state.slider)) return;
98 |
99 | state.elapsed = state.elapsed + (Date.now() - state.lastUpdate) / 1000;
100 | state.lastUpdate = Date.now();
101 |
102 | if (e.layerX > state.currentX) {
103 | // volume down
104 | state.elapsed += 2;
105 | } else {
106 | // volume up
107 | state.elapsed -= 2;
108 | }
109 |
110 | state.slideInProgress = true;
111 | setPosition(state.elapsed / state.duration);
112 | updatePosition(true);
113 | clearTimeout(progressAdjustTimer);
114 | progressAdjustTimer = setTimeout(function () {
115 | callback(state.elapsed / state.duration);
116 | state.slideInProgress = false
117 | }, 2000);
118 | }
119 |
120 | function onDrag(e) {
121 | var deltaX = e.clientX - state.cursorX;
122 | var nextX = state.originalX + deltaX;
123 | // calculate time
124 | if (nextX < 1) nextX = 1;
125 | if (nextX > state.maxX) nextX = state.maxX;
126 | var progress = nextX / state.maxX;
127 |
128 | setPosition(progress);
129 | state.hasBeenDragged = true;
130 | }
131 |
132 | var sliderWidth = containerObj.clientWidth;
133 | state.maxX = sliderWidth - 5;
134 | state.slider = containerObj.querySelector('div');
135 | state.currentX = state.slider.offsetLeft;
136 |
137 | state.slider.addEventListener('mousedown', function (e) {
138 | state.slideInProgress = true;
139 | state.cursorX = e.clientX;
140 | state.originalX = state.currentX;
141 | state.slider.classList.add('sliding');
142 | document.addEventListener('mousemove', onDrag);
143 | e.preventDefault();
144 | });
145 |
146 | document.addEventListener('mouseup', function () {
147 | if (!state.slideInProgress || !state.hasBeenDragged) return;
148 | document.removeEventListener('mousemove', onDrag);
149 |
150 | var progress = state.currentX / state.maxX;
151 | if (typeof callback == "function") {
152 | callback(progress);
153 | }
154 |
155 | state.elapsed = Math.round(state.duration * progress);
156 | state.lastUpdate = Date.now();
157 | state.slider.classList.remove('sliding');
158 | state.slideInProgress = false;
159 | state.hasBeenDragged = false;
160 | });
161 |
162 | // Since Chrome 31 wheel event is also supported
163 | containerObj.addEventListener("wheel", handleMouseWheel);
164 |
165 | // For click-to-adjust
166 | containerObj.addEventListener("click", handleClick);
167 | }
--------------------------------------------------------------------------------
/static/js/gui-events.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | ///
4 | /// GUI events
5 | ///
6 |
7 | document.getElementById('zone-container').addEventListener('click', function (e) {
8 | // Find the actual UL
9 | function findZoneNode(currentNode) {
10 | // If we are at top level, abort.
11 | if (currentNode == this) return;
12 | if (currentNode.tagName == "UL") return currentNode;
13 | return findZoneNode(currentNode.parentNode);
14 | }
15 |
16 | var zone = findZoneNode(e.target);
17 |
18 | if (!zone) return;
19 |
20 | var previousZone = document.getElementById(Sonos.currentState.selectedZone);
21 | if (previousZone) previousZone.classList.remove('selected');
22 |
23 | Sonos.currentState.selectedZone = zone.id;
24 | zone.classList.add('selected');
25 | // Update controls with status
26 | updateControllerState();
27 | updateCurrentStatus();
28 |
29 | // fetch queue
30 | Socket.socket.emit('queue', {uuid: Sonos.currentState.selectedZone});
31 |
32 | }, true);
33 |
34 | document.getElementById('master-mute').addEventListener('click', function () {
35 |
36 | var action;
37 | // Find state of current player
38 | var player = Sonos.currentZoneCoordinator();
39 |
40 | // current state
41 | var mute = player.groupState.mute;
42 | Socket.socket.emit('group-mute', {uuid: player.uuid, mute: !mute});
43 |
44 | // update
45 | if (mute)
46 | this.src = this.src.replace(/_on\.svg/, '_off.svg');
47 | else
48 | this.src = this.src.replace(/_off\.svg/, '_on.svg');
49 |
50 | });
51 |
52 | document.getElementById('play').addEventListener('click', function () {
53 |
54 | // var action;
55 | // Find state of current player
56 | // var player = Sonos.currentZoneCoordinator();
57 | // if (player.state.zoneState == "PLAYING" ) {
58 | // action = 'pause';
59 | // } else {
60 | // action = 'play';
61 | // }
62 |
63 | Socket.socket.emit('transport-state', { uuid: Sonos.currentState.selectedZone, state: 'play' });
64 | });
65 |
66 | document.getElementById('pause').addEventListener('click', function () {
67 |
68 | // var action;
69 | // Find state of current player
70 | // var player = Sonos.currentZoneCoordinator();
71 | // if (player.state.zoneState == "PLAYING" ) {
72 | // action = 'pause';
73 | // } else {
74 | // action = 'play';
75 | // }
76 |
77 | Socket.socket.emit('transport-state', { uuid: Sonos.currentState.selectedZone, state: 'pause' });
78 | });
79 |
80 | document.getElementById('next').addEventListener('click', function () {
81 | var action = "nextTrack";
82 | Socket.socket.emit('transport-state', { uuid: Sonos.currentState.selectedZone, state: action });
83 | });
84 | document.getElementById('prev').addEventListener('click', function () {
85 | var action = "previousTrack";
86 | Socket.socket.emit('transport-state', { uuid: Sonos.currentState.selectedZone, state: action });
87 | });
88 |
89 | document.getElementById('music-sources-container').addEventListener('dblclick', function (e) {
90 | function findFavoriteNode(currentNode) {
91 | // If we are at top level, abort.
92 | if (currentNode == this) return;
93 | if (currentNode.tagName == "LI") return currentNode;
94 | return findFavoriteNode(currentNode.parentNode);
95 | }
96 | var li = findFavoriteNode(e.target);
97 | Socket.socket.emit('play-favorite', {uuid: Sonos.currentState.selectedZone, favorite: li.dataset.title});
98 | });
99 |
100 | document.getElementById('status-container').addEventListener('dblclick', function (e) {
101 | function findQueueNode(currentNode) {
102 | // If we are at top level, abort.
103 | if (currentNode == this) return;
104 | if (currentNode.tagName == "LI") return currentNode;
105 | return findQueueNode(currentNode.parentNode);
106 | }
107 | var li = findQueueNode(e.target);
108 | if (!li) return;
109 | Socket.socket.emit('seek', {uuid: Sonos.currentState.selectedZone, trackNo: li.dataset.trackNo});
110 | });
111 |
112 | document.getElementById('position-info').addEventListener('click', function (e) {
113 | function findActionNode(currentNode) {
114 | if (currentNode == this) return;
115 | if (currentNode.className == "playback-mode") return currentNode;
116 | return findActionNode(currentNode.parentNode);
117 | }
118 |
119 | var actionNode = findActionNode(e.target);
120 | if (!actionNode) return;
121 |
122 | var action = actionNode.id;
123 | var data = {};
124 | var state = /off/.test(actionNode.src) ? true : false;
125 | data[action] = state;
126 |
127 | var selectedZone = Sonos.currentZoneCoordinator();
128 | // set this directly for instant feedback
129 | selectedZone.state.playMode[action] = state;
130 | // updateCurrentStatus();
131 | Socket.socket.emit('playmode', {uuid: Sonos.currentState.selectedZone, state: data});
132 |
133 | });
134 |
135 | document.getElementById('player-volumes-container').addEventListener('click', function (e) {
136 | var muteButton = e.target;
137 | if (!muteButton.classList.contains('mute-button')) return;
138 |
139 |
140 |
141 | // this is a mute button, go.
142 | var player = Sonos.players[muteButton.dataset.id];
143 | var state = !player.state.mute;
144 | Socket.socket.emit('mute', {uuid: player.uuid, mute: state});
145 |
146 | // update GUI
147 | // update
148 | if (state)
149 | muteButton.src = muteButton.src.replace(/_off\.svg/, '_on.svg');
150 | else
151 | muteButton.src = muteButton.src.replace(/_on\.svg/, '_off.svg');
152 |
153 | });
154 |
155 | document.getElementById("current-track-art").addEventListener('load', function (e) {
156 | // new image loaded. update favicon
157 | // This prevents duplicate requests!
158 | var oldFavicon = document.getElementById("favicon");
159 | var newFavicon = oldFavicon.cloneNode();
160 | newFavicon.href = this.src;
161 | newFavicon.type = "image/png";
162 | oldFavicon.parentNode.replaceChild(newFavicon, oldFavicon);
163 |
164 | });
165 |
166 | var searchTimer = null;
167 | // document.getElementById('searchfield').addEventListener('keyup', function (e) {
168 | // if (searchTimer) clearTimeout(searchTimer);
169 | // var searchTerm = this.value;
170 | // searchTimer = setTimeout(function () {
171 | // Socket.socket.emit('search', { type: 'localIndex', term: searchTerm });
172 | // }, 500);
173 | // });
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | const http = require('http');
3 | const StaticServer = require('node-static').Server;
4 | const io = require('socket.io');
5 | const fs = require('fs');
6 | const path = require('path');
7 | const crypto = require('crypto');
8 | const async = require('async');
9 | const SonosDiscovery = require('sonos-discovery');
10 | const settings = {
11 | port: 8080,
12 | cacheDir: './cache'
13 | }
14 |
15 | try {
16 | const userSettings = require(path.resolve(__dirname, 'settings.json'));
17 | for (var i in userSettings) {
18 | settings[i] = userSettings[i];
19 | }
20 | } catch (e) {
21 | console.log('no settings file found, will only use default settings');
22 | }
23 |
24 | var discovery = new SonosDiscovery(settings);
25 |
26 | var cacheDir = path.resolve(__dirname, settings.cacheDir);
27 | var missingAlbumArt = path.resolve(__dirname, './lib/browse_missing_album_art.png');
28 |
29 | var fileServer = new StaticServer(path.resolve(__dirname, 'static'));
30 |
31 | var queues = {};
32 |
33 | fs.mkdir(cacheDir, function (e) {
34 | if (e && e.code != 'EEXIST')
35 | console.log('creating cache dir failed!', e);
36 | });
37 |
38 | var server = http.createServer(function (req, res) {
39 | if (/^\/getaa/.test(req.url)) {
40 | // this is a resource, download from player and put in cache folder
41 | var md5url = crypto.createHash('md5').update(req.url).digest('hex');
42 | var fileName = path.join(cacheDir, md5url);
43 |
44 | fs.exists(fileName, function (exists) {
45 | if (exists) {
46 | var readCache = fs.createReadStream(fileName);
47 | readCache.pipe(res);
48 | return;
49 | }
50 |
51 | const player = discovery.getAnyPlayer();
52 | if (!player) return;
53 |
54 | console.log('fetching album art from', player.localEndpoint);
55 | http.get(`${player.baseUrl}${req.url}`, function (res2) {
56 | console.log(res2.statusCode);
57 | if (res2.statusCode == 200) {
58 | if (!fs.exists(fileName)) {
59 | var cacheStream = fs.createWriteStream(fileName);
60 | res2.pipe(cacheStream);
61 | } else {
62 | res2.resume();
63 | }
64 | } else if (res2.statusCode == 404) {
65 | // no image exists! link it to the default image.
66 | console.log(res2.statusCode, 'linking', fileName)
67 | fs.link(missingAlbumArt, fileName, function (e) {
68 | res2.resume();
69 | if (e) console.log(e);
70 | });
71 | }
72 |
73 | res2.on('end', function () {
74 | console.log('serving', req.url);
75 | var readCache = fs.createReadStream(fileName);
76 | readCache.on('error', function (e) {
77 | console.log(e);
78 | });
79 | readCache.pipe(res);
80 | });
81 | }).on('error', function (e) {
82 | console.log("Got error: " + e.message);
83 | });
84 | });
85 | } else {
86 | req.addListener('end', function () {
87 | fileServer.serve(req, res);
88 | }).resume();
89 | }
90 | });
91 |
92 | var socketServer = io.listen(server);
93 |
94 | socketServer.sockets.on('connection', function (socket) {
95 | // Send it in a better format
96 | const players = discovery.players;
97 |
98 | if (players.length == 0) return;
99 |
100 | socket.emit('topology-change', players);
101 | discovery.getFavorites()
102 | .then((favorites) => {
103 | socket.emit('favorites', favorites);
104 | });
105 |
106 | socket.on('transport-state', function (data) {
107 | // find player based on uuid
108 | const player = discovery.getPlayerByUUID(data.uuid);
109 |
110 | if (!player) return;
111 |
112 | // invoke action
113 | //console.log(data)
114 | player[data.state]();
115 | });
116 |
117 | socket.on('group-volume', function (data) {
118 | // find player based on uuid
119 | const player = discovery.getPlayerByUUID(data.uuid);
120 | if (!player) return;
121 |
122 | // invoke action
123 | player.setGroupVolume(data.volume);
124 | });
125 |
126 | socket.on('group-management', function (data) {
127 | // find player based on uuid
128 | console.log(data)
129 | const player = discovery.getPlayerByUUID(data.player);
130 | if (!player) return;
131 |
132 | if (data.group == null) {
133 | player.becomeCoordinatorOfStandaloneGroup();
134 | return;
135 | }
136 |
137 | player.setAVTransport(`x-rincon:${data.group}`);
138 | });
139 |
140 | socket.on('play-favorite', function (data) {
141 | var player = discovery.getPlayerByUUID(data.uuid);
142 | if (!player) return;
143 |
144 | player.replaceWithFavorite(data.favorite)
145 | .then(() => player.play());
146 | });
147 |
148 | socket.on('queue', function (data) {
149 | loadQueue(data.uuid)
150 | .then(queue => {
151 | socket.emit('queue', { uuid: data.uuid, queue });
152 | });
153 | });
154 |
155 | socket.on('seek', function (data) {
156 | var player = discovery.getPlayerByUUID(data.uuid);
157 | if (player.avTransportUri.startsWith('x-rincon-queue')) {
158 | player.trackSeek(data.trackNo);
159 | return;
160 | }
161 |
162 | // Player is not using queue, so start queue first
163 | player.setAVTransport('x-rincon-queue:' + player.uuid + '#0')
164 | .then(() => player.trackSeek(data.trackNo))
165 | .then(() => player.play());
166 | });
167 |
168 | socket.on('playmode', function (data) {
169 | var player = discovery.getPlayerByUUID(data.uuid);
170 | for (var action in data.state) {
171 | player[action](data.state[action]);
172 | }
173 | });
174 |
175 | socket.on('volume', function (data) {
176 | var player = discovery.getPlayerByUUID(data.uuid);
177 | player.setVolume(data.volume);
178 | });
179 |
180 | socket.on('group-mute', function (data) {
181 | console.log(data)
182 | var player = discovery.getPlayerByUUID(data.uuid);
183 | if (data.mute)
184 | player.muteGroup();
185 | else
186 | player.unMuteGroup();
187 | });
188 |
189 | socket.on('mute', function (data) {
190 | var player = discovery.getPlayerByUUID(data.uuid);
191 | if (data.mute)
192 | player.mute();
193 | else
194 | player.unMute();
195 | });
196 |
197 | socket.on('track-seek', function (data) {
198 | var player = discovery.getPlayerByUUID(data.uuid);
199 | player.timeSeek(data.elapsed);
200 | });
201 |
202 | socket.on('search', function (data) {
203 | search(data.term, socket);
204 | });
205 |
206 | socket.on("error", function (e) {
207 | console.error(e);
208 | })
209 | });
210 |
211 | discovery.on('topology-change', function (data) {
212 | socketServer.sockets.emit('topology-change', discovery.players);
213 | });
214 |
215 | discovery.on('transport-state', function (data) {
216 | socketServer.sockets.emit('transport-state', data);
217 | });
218 |
219 | discovery.on('group-volume', function (data) {
220 | socketServer.sockets.emit('group-volume', data);
221 | });
222 |
223 | discovery.on('volume-change', function (data) {
224 | socketServer.sockets.emit('volume', data);
225 | });
226 |
227 | discovery.on('group-mute', function (data) {
228 | socketServer.sockets.emit('group-mute', data);
229 | });
230 |
231 | discovery.on('mute-change', function (data) {
232 | socketServer.sockets.emit('mute', data);
233 | });
234 |
235 | discovery.on('favorites', function (data) {
236 | socketServer.sockets.emit('favorites', data);
237 | });
238 |
239 | discovery.on('queue-change', function (player) {
240 | console.log('queue-changed', player.roomName);
241 | delete queues[player.uuid];
242 | loadQueue(player.uuid)
243 | .then(queue => {
244 | socketServer.sockets.emit('queue', { uuid: player.uuid, queue });
245 | });
246 | });
247 |
248 | function loadQueue(uuid) {
249 | if (queues[uuid]) {
250 | return Promise.resolve(queues[uuid]);
251 | }
252 |
253 | const player = discovery.getPlayerByUUID(uuid);
254 | return player.getQueue()
255 | .then(queue => {
256 | queues[uuid] = queue;
257 | return queue;
258 | });
259 | }
260 |
261 | function search(term, socket) {
262 | console.log('search for', term)
263 | var playerCycle = 0;
264 | var players = [];
265 |
266 | for (var i in discovery.players) {
267 | players.push(discovery.players[i]);
268 | }
269 |
270 | function getPlayer() {
271 | var player = players[playerCycle++ % players.length];
272 | return player;
273 | }
274 |
275 | var response = {};
276 |
277 | async.parallelLimit([
278 | function (callback) {
279 | var player = getPlayer();
280 | console.log('fetching from', player.address)
281 | player.browse('A:ARTIST:' + term, 0, 600, function (success, result) {
282 | console.log(success, result)
283 | response.byArtist = result;
284 | callback(null, 'artist');
285 | });
286 | },
287 | function (callback) {
288 | var player = getPlayer();
289 | console.log('fetching from', player.address)
290 | player.browse('A:TRACKS:' + term, 0, 600, function (success, result) {
291 | response.byTrack = result;
292 | callback(null, 'track');
293 | });
294 | },
295 | function (callback) {
296 | var player = getPlayer();
297 | console.log('fetching from', player.address)
298 | player.browse('A:ALBUM:' + term, 0, 600, function (success, result) {
299 | response.byAlbum = result;
300 | callback(null, 'album');
301 | });
302 | }
303 | ], players.length, function (err, result) {
304 |
305 | socket.emit('search-result', response);
306 | });
307 | }
308 |
309 | // Attach handler for socket.io
310 |
311 | server.listen(settings.port);
312 |
313 | console.log("http server listening on port", settings.port);
314 |
--------------------------------------------------------------------------------
/static/js/all.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | Socket.topologyChanged = function (shouldRenderVolumes) {
4 | if (shouldRenderVolumes) renderVolumes();
5 |
6 | reRenderZones();
7 | updateControllerState();
8 | updateCurrentStatus();
9 | }
10 |
11 | Socket.transportStateChanged = function (player) {
12 | if (player.state.playerState == 'TRANSITIONING') return;
13 | reRenderZones();
14 | updateControllerState();
15 | updateCurrentStatus();
16 | }
17 |
18 | Socket.groupVolumeChanged = function (data) {
19 | Sonos.players[data.uuid].groupState.volume = data.newVolume;
20 |
21 | if (data.uuid == Sonos.currentState.selectedZone) {
22 | GUI.masterVolume.setVolume(data.newVolume);
23 | }
24 | }
25 |
26 | Socket.volumeChanged = function (data) {
27 | Sonos.players[data.uuid].state.volume = data.newVolume;
28 | GUI.playerVolumes[data.uuid].setVolume(data.newVolume);
29 |
30 | }
31 |
32 | Socket.groupMuteChanged = function (data) {
33 | updateControllerState();
34 | }
35 |
36 | Socket.muteChanged = function (data) {
37 | var player = Sonos.players[data.uuid];
38 | player.state.mute = data.newMute;
39 | document.getElementById("mute-" + data.uuid).src = data.newMute ? 'svg/mute_on.svg' : 'svg/mute_off.svg';
40 | }
41 |
42 | Socket.favoritesChanged = function (data) {
43 | renderFavorites(data);
44 | }
45 |
46 | Socket.queueChanged = function (data) {
47 | if (data.uuid != Sonos.currentState.selectedZone) return;
48 | renderQueue(data.queue);
49 | }
50 |
51 | Socket.searchResultReceived = function (data) {
52 | renderSearchResult(data);
53 | }
54 |
55 | ///
56 | /// ACTIONS
57 | ///
58 |
59 | function updateCurrentStatus() {
60 | var selectedZone = Sonos.currentZoneCoordinator();
61 |
62 | document.getElementById('page-title').textContent = selectedZone.state.currentTrack.title + ' - Sonos Web Controller';
63 |
64 | var prefix = (window.location.pathname != '/') ? window.location.pathname : ''
65 | if (selectedZone.state.currentTrack.type == 'radio') {
66 | // update radio
67 | document.getElementById("current-radio-art").src = selectedZone.state.currentTrack.absoluteAlbumArtUri;
68 | document.getElementById("station").textContent = selectedZone.state.currentTrack.title;
69 | document.getElementById("information").textContent = selectedZone.state.currentTrack.streamInfo;
70 | document.getElementById("status-container").className = "radio";
71 |
72 | } else {
73 | document.getElementById("current-track-art").src = selectedZone.state.currentTrack.absoluteAlbumArtUri;
74 | document.getElementById("track").textContent = selectedZone.state.currentTrack.title;
75 | document.getElementById("artist").textContent = selectedZone.state.currentTrack.artist;
76 | document.getElementById("album").textContent = selectedZone.state.currentTrack.album;
77 |
78 | if (selectedZone.state.nextTrack) {
79 | var nextTrack = selectedZone.state.nextTrack;
80 | document.getElementById("next-track").textContent = nextTrack.title + " - " + nextTrack.artist;
81 | }
82 | document.getElementById("status-container").className = "track";
83 | }
84 |
85 | var repeat = document.getElementById("repeat");
86 | if (selectedZone.state.playMode.repeat !== 'none') {
87 | repeat.src = repeat.src.replace(/_off\.png/, "_on.png");
88 | } else {
89 | repeat.src = repeat.src.replace(/_on\.png/, "_off.png");
90 | }
91 |
92 | var shuffle = document.getElementById("shuffle");
93 | if (selectedZone.state.playMode.shuffle) {
94 | shuffle.src = shuffle.src.replace(/_off\.png/, "_on.png");
95 | } else {
96 | shuffle.src = shuffle.src.replace(/_on\.png/, "_off.png");
97 | }
98 |
99 | var crossfade = document.getElementById("crossfade");
100 | if (selectedZone.state.playMode.crossfade) {
101 | crossfade.src = crossfade.src.replace(/_off\.png/, "_on.png");
102 | } else {
103 | crossfade.src = crossfade.src.replace(/_on\.png/, "_off.png");
104 | }
105 |
106 | GUI.progress.update(selectedZone);
107 | }
108 |
109 | function updateControllerState() {
110 | var currentZone = Sonos.currentZoneCoordinator();
111 | var state = currentZone.state;
112 | var playPause = document.getElementById('play-pause');
113 |
114 | if (state.playbackState === 'PLAYING') {
115 | playPause.className = 'pause';
116 | } else if (state.playbackState === 'PAUSED_PLAYBACK' || state.playbackState === 'STOPPED') {
117 | playPause.className = 'play';
118 | }
119 |
120 | // Fix volume
121 | GUI.masterVolume.setVolume(currentZone.groupState.volume);
122 |
123 | // fix mute
124 | var masterMute = document.getElementById('master-mute');
125 | if (currentZone.groupState.mute) {
126 | masterMute.src = "svg/mute_on.svg";
127 | } else {
128 | masterMute.src = "svg/mute_off.svg";
129 | }
130 |
131 | // fix volume container
132 |
133 | var allVolumes = {};
134 | for (var uuid in Sonos.players) {
135 | // is this in group?
136 | allVolumes[uuid] = null;
137 | }
138 |
139 | Sonos.grouping[Sonos.currentState.selectedZone].forEach(function (uuid) {
140 | document.getElementById("volume-" + uuid).classList.remove("hidden");
141 | delete allVolumes[uuid];
142 | });
143 |
144 | // now, hide the ones left
145 | for (var uuid in allVolumes) {
146 | document.getElementById("volume-" + uuid).classList.add("hidden");
147 | }
148 |
149 | }
150 |
151 | function toFormattedTime(seconds) {
152 | var chunks = [];
153 | var modulus = [60 ^ 2, 60];
154 | var remainingTime = seconds;
155 | // hours
156 | var hours = Math.floor(remainingTime / 3600);
157 |
158 | if (hours > 0) {
159 | chunks.push(zpad(hours, 1));
160 | remainingTime -= hours * 3600;
161 | }
162 |
163 | // minutes
164 | var minutes = Math.floor(remainingTime / 60);
165 | // If we have hours, pad minutes, otherwise not.
166 | var padding = chunks.length > 0 ? 2 : 1;
167 | chunks.push(zpad(minutes, padding));
168 | remainingTime -= minutes * 60;
169 | // seconds
170 | chunks.push(zpad(Math.floor(remainingTime), 2))
171 | return chunks.join(':');
172 | }
173 |
174 | function zpad(number, width) {
175 | var str = number + "";
176 | if (str.length >= width) return str;
177 | var padding = new Array(width - str.length + 1).join('0');
178 | return padding + str;
179 | }
180 |
181 | var zoneManagement = function () {
182 |
183 | var dragItem;
184 |
185 | function findZoneNode(currentNode) {
186 | // If we are at top level, abort.
187 | if (currentNode == this) return;
188 | if (currentNode.tagName == "UL") return currentNode;
189 | return findZoneNode(currentNode.parentNode);
190 | }
191 |
192 | function handleDragStart(e) {
193 | e.dataTransfer.effectAllowed = 'move';
194 | e.dataTransfer.setData('text/html', e.target.innerHTML);
195 | dragItem = e.target;
196 | dragItem.classList.add('drag');
197 | }
198 |
199 | function handleDragEnd(e) {
200 | dragItem.classList.remove('drag');
201 | }
202 |
203 | function handleDrop(e) {
204 | if (e.target.parentNode == this) {
205 | // detach
206 | Socket.socket.emit('group-management', { player: dragItem.dataset.id, group: null });
207 | return;
208 | }
209 |
210 | var zone = findZoneNode(e.target);
211 | if (!zone || zone == this.parentNode) return;
212 |
213 | Socket.socket.emit('group-management', { player: dragItem.dataset.id, group: zone.id });
214 |
215 | }
216 |
217 | function handleDragOver(e) {
218 | e.preventDefault();
219 | e.dataTransfer.dropEffect = 'move';
220 |
221 | }
222 |
223 | document.getElementById('zone-container').addEventListener('dragstart', handleDragStart);
224 | document.getElementById('zone-container').addEventListener('dragend', handleDragEnd);
225 | document.getElementById('zone-container').addEventListener('dragover', handleDragOver);
226 | document.getElementById('zone-container').addEventListener('drop', handleDrop);
227 |
228 | }();
229 |
230 | function renderVolumes() {
231 | var oldWrapper = document.getElementById('player-volumes');
232 | var newWrapper = oldWrapper.cloneNode(false);
233 | var masterVolume = document.getElementById('master-volume');
234 | var masterMute = document.getElementById('master-mute');
235 |
236 | var playerNodes = [];
237 |
238 | for (var i in Sonos.players) {
239 | var player = Sonos.players[i];
240 | var playerVolumeBar = masterVolume.cloneNode(true);
241 | var playerVolumeBarContainer = document.createElement('div');
242 | playerVolumeBarContainer.id = "volume-" + player.uuid;
243 | playerVolumeBar.id = "";
244 | playerVolumeBar.dataset.uuid = player.uuid;
245 | var playerName = document.createElement('h6');
246 | var playerMute = masterMute.cloneNode(true);
247 | playerMute.id = "mute-" + player.uuid;
248 | playerMute.className = "mute-button";
249 | playerMute.src = player.state.mute ? "svg/mute_on.svg" : "svg/mute_off.svg";
250 | playerMute.dataset.id = player.uuid;
251 | playerName.textContent = player.roomName;
252 | playerVolumeBarContainer.appendChild(playerName);
253 | playerVolumeBarContainer.appendChild(playerMute);
254 | playerVolumeBarContainer.appendChild(playerVolumeBar);
255 | newWrapper.appendChild(playerVolumeBarContainer);
256 | playerNodes.push({ uuid: player.uuid, node: playerVolumeBar });
257 | }
258 |
259 | oldWrapper.parentNode.replaceChild(newWrapper, oldWrapper);
260 |
261 | // They need to be part of DOM before initialization
262 | playerNodes.forEach(function (playerPair) {
263 | var uuid = playerPair.uuid;
264 | var node = playerPair.node;
265 | GUI.playerVolumes[uuid] = new VolumeSlider(node, function (vol) {
266 | Socket.socket.emit('volume', { uuid: uuid, volume: vol });
267 | });
268 |
269 | GUI.playerVolumes[uuid].setVolume(Sonos.players[uuid].state.volume);
270 | });
271 |
272 | newWrapper.classList.add('hidden');
273 | newWrapper.classList.remove('loading');
274 | }
275 |
276 | function reRenderZones() {
277 | var oldWrapper = document.getElementById('zone-wrapper');
278 | var newWrapper = oldWrapper.cloneNode(false);
279 |
280 | for (var groupUUID in Sonos.grouping) {
281 | var ul = document.createElement('ul');
282 | ul.id = groupUUID;
283 |
284 | if (ul.id == Sonos.currentState.selectedZone)
285 | ul.className = "selected";
286 |
287 | Sonos.grouping[groupUUID].forEach(function (playerUUID) {
288 | var player = Sonos.players[playerUUID];
289 | var li = document.createElement('li');
290 | var span = document.createElement('span');
291 | span.textContent = player.roomName;
292 | li.appendChild(span);
293 | li.draggable = true;
294 | li.dataset.id = playerUUID;
295 | ul.appendChild(li);
296 | });
297 |
298 | newWrapper.appendChild(ul);
299 | }
300 | oldWrapper.parentNode.replaceChild(newWrapper, oldWrapper);
301 | }
302 |
303 | function renderFavorites(favorites) {
304 | var oldContainer = document.getElementById('favorites-container');
305 | var newContainer = oldContainer.cloneNode(false);
306 |
307 | var i = 0;
308 |
309 | favorites.forEach(function (favorite) {
310 | var li = document.createElement('li');
311 | li.dataset.title = favorite.title;
312 | var span = document.createElement('span');
313 | span.textContent = favorite.title;
314 | var albumArt = document.createElement('img');
315 | albumArt.addEventListener('error', imageErrorHandler);
316 | albumArt.src = favorite.albumArtUri;
317 | li.appendChild(albumArt);
318 | li.appendChild(span);
319 | li.tabIndex = i++;
320 | newContainer.appendChild(li);
321 | });
322 |
323 | oldContainer.parentNode.replaceChild(newContainer, oldContainer);
324 | }
325 |
326 | function imageErrorHandler() {
327 | this.removeEventListener('error', imageErrorHandler);
328 | this.src = "images/browse_missing_album_art.png";
329 | }
330 |
331 | function renderQueue(queue) {
332 | var tempContainer = document.createDocumentFragment();
333 | var trackIndex = 1;
334 | var scrollTimeout;
335 |
336 | queue.forEach(function (q) {
337 | var li = document.createElement('li');
338 | li.dataset.title = q.uri;
339 | li.dataset.trackNo = trackIndex++;
340 | li.tabIndex = trackIndex;
341 |
342 | var albumArt = document.createElement('img');
343 | var prefix = (window.location.pathname != '/') ? window.location.pathname : '';
344 | albumArt.dataset.src = prefix + q.albumArtUri;
345 | if (trackIndex < 20) {
346 | albumArt.src = prefix + q.albumArtUri;
347 | albumArt.className = "loaded";
348 | }
349 |
350 | li.appendChild(albumArt);
351 |
352 | var trackInfo = document.createElement('div');
353 | var title = document.createElement('p');
354 | title.className = 'title';
355 | title.textContent = q.title;
356 | trackInfo.appendChild(title);
357 | var artist = document.createElement('p');
358 | artist.className = 'artist';
359 | artist.textContent = q.artist;
360 | trackInfo.appendChild(artist);
361 |
362 | li.appendChild(trackInfo);
363 | tempContainer.appendChild(li);
364 | });
365 |
366 | var oldContainer = document.getElementById('queue-container');
367 | var newContainer = oldContainer.cloneNode(false);
368 | newContainer.addEventListener('scroll', function (e) {
369 | clearTimeout(scrollTimeout);
370 | var _this = this;
371 | scrollTimeout = setTimeout(function () {
372 | lazyLoadImages(_this);
373 | }, 150);
374 |
375 | });
376 | newContainer.appendChild(tempContainer);
377 | oldContainer.parentNode.replaceChild(newContainer, oldContainer);
378 | }
379 |
380 | function lazyLoadImages(container) {
381 | // Find elements that are in viewport
382 | var containerViewport = container.getBoundingClientRect();
383 | // best estimate of starting point
384 | var trackHeight = container.firstChild.scrollHeight;
385 |
386 | // startIndex
387 | var startIndex = Math.floor(container.scrollTop / trackHeight);
388 | var currentNode = container.childNodes[startIndex];
389 |
390 | while (currentNode && currentNode.getBoundingClientRect().top < containerViewport.bottom) {
391 | var img = currentNode.firstChild;
392 | currentNode = currentNode.nextSibling;
393 | if (img.className == 'loaded') {
394 | continue;
395 | }
396 |
397 | // get image
398 | img.src = img.dataset.src;
399 | img.className = 'loaded';
400 |
401 | }
402 | }
403 |
404 | function renderSearchResult(result) {
405 |
406 | }
407 |
408 |
--------------------------------------------------------------------------------
/static/css/main.css:
--------------------------------------------------------------------------------
1 | body {
2 | background-image: linear-gradient(top, rgb(81,81,81) 30px, rgb(0,0,0) 130px);
3 | background-image: -moz-linear-gradient(top, rgb(81,81,81) 30px, rgb(0,0,0) 130px);
4 | background-image: -webkit-linear-gradient(top, rgb(81,81,81) 30px, rgb(0,0,0) 130px);
5 | background-image: -ms-linear-gradient(top, rgb(81,81,81) 30px, rgb(0,0,0) 130px);
6 | margin: 5px 5px 0 5px;
7 | color: #bbb;
8 | }
9 |
10 | ::-webkit-scrollbar {
11 | width: 10px;
12 | height: 10px;
13 | }
14 | ::-webkit-scrollbar-track {
15 | background: #021B2E;
16 | }
17 | ::-webkit-scrollbar-thumb {
18 | background: #053A66;
19 | border-radius: 10px;
20 | }
21 | ::-webkit-scrollbar-thumb:hover {
22 | background: #07569B;
23 | }
24 |
25 | h4 {
26 | font-size: 100%;
27 | }
28 |
29 | #top-control {
30 | margin-bottom: 10px;
31 | position: relative;
32 | }
33 |
34 | #top-control #play-pause,
35 | #top-control #prev,
36 | #top-control #next,
37 | .mute-button {
38 | border-radius: 22px;
39 | width: 44px;
40 | height: 46px;
41 | border-top: 2px solid rgb(138,138,138);
42 | border-bottom: 2px solid rgb(46,46,46);
43 | background: linear-gradient(to bottom, rgb(128,128,128), rgb(72,72,72));
44 | -moz-box-sizing: border-box;
45 | -webkit-box-sizing: border-box;
46 | vertical-align: middle;
47 | box-shadow: 0 0 1px rgb(56,56,56);
48 | }
49 |
50 | #top-control #prev,
51 | #top-control #next,
52 | .mute-button {
53 | width: 32px;
54 | height: 32px;
55 | }
56 |
57 | #top-control #play-pause:hover,
58 | #top-control #prev:hover,
59 | #top-control #next:hover,
60 | .mute-button:hover {
61 | background: linear-gradient(to bottom, rgb(130,130,130), rgb(90,90,90));
62 | border-top: 2px solid rgb(150,150,150);
63 | border-bottom: 2px solid rgb(64,64,64);
64 |
65 | }
66 |
67 | #top-control #play-pause:active,
68 | #top-control #prev:active,
69 | #top-control #next:active,
70 | .mute-button:active {
71 | background: linear-gradient(to top, rgb(100,100,100), rgb(64,64,64));
72 | border-top: 2px solid rgb(46,46,46);
73 | border-bottom: 2px solid rgb(64,64,64);
74 |
75 | }
76 |
77 | /* play-pause toggle */
78 | #play-pause {
79 | display: inline-block;
80 | }
81 |
82 | #play-pause #play,
83 | #play-pause #pause {
84 | width: 44px;
85 | height: 42px;
86 | }
87 |
88 | #play-pause.play #pause {
89 | display: none;
90 | }
91 |
92 | #play-pause.pause #play {
93 | display: none;
94 | }
95 |
96 | /* grey container */
97 | #top-control {
98 | border-radius: 5px;
99 | border-top: 2px solid #1A1A1A;
100 | border-left: 2px solid #1A1A1A;
101 | border-bottom: 2px solid #2D2C2C;
102 | border-right: 2px solid #2D2C2C;
103 |
104 | background: #000;
105 | height: 80px;
106 | background-image: -moz-linear-gradient(bottom, rgb(37,37,37) 5%, rgb(81,81,81) 95%);
107 | background-image: -webkit-linear-gradient(bottom, rgb(37,37,37) 5%, rgb(81,81,81) 95%);
108 | background-image: -ms-linear-gradient(bottom, rgb(37,37,37) 5%, rgb(81,81,81) 95%);
109 | background-image: linear-gradient(bottom, rgb(37,37,37) 5%, rgb(81,81,81) 95%);
110 | }
111 |
112 | #search {
113 | position: absolute;
114 | right: 50px;
115 | top: 30px;
116 | }
117 |
118 | #search input {
119 | height: 16px;
120 | padding: 2px 8px;
121 | border-bottom-left-radius: 8px;
122 | border-top-left-radius: 8px;
123 | border: none;
124 | }
125 | #search button {
126 | height: 20px;
127 | padding: 2px 4px;
128 | border-bottom-right-radius: 8px;
129 | border-top-right-radius: 8px;
130 | border: none;
131 | }
132 |
133 | /* blue container */
134 | #zone-container h4,
135 | #status-container h4,
136 | #music-sources-container h4 {
137 | background-image: -moz-linear-gradient(top, #002C4D 2%, #001C38 90%);
138 | background-image: -webkit-linear-gradient(top, #002C4D 2%, #001C38 90%);
139 | background-image: linear-gradient(top, #002C4D 2%, #001C38 90%);
140 | margin: 0;
141 | color: #0A97FF;
142 | font-size: 100%;
143 | }
144 |
145 | #status-container h4#now-playing {
146 | background-image: -moz-linear-gradient(top, rgb(7,79,131) 0%, rgb(9,58,94) 100%);
147 | background-image: -webkit-linear-gradient(top, rgb(7,79,131) 0%, rgb(9,58,94) 100%);
148 | background-image: linear-gradient(top, rgb(7,79,131) 0%, rgb(9,58,94) 100%);
149 | border-bottom: 1px solid rgb(1,36,64);
150 | color: #fff;
151 | }
152 |
153 | #status-container h4#queue {
154 | border-radius: 0;
155 | }
156 |
157 | #status-container.radio #current-track-info {
158 | display: none;
159 | }
160 | #status-container.radio #current-radio-info {
161 | display: block;
162 | }
163 |
164 | #status-container.track #current-track-info {
165 | display: block;
166 | }
167 | #status-container.track #current-radio-info {
168 | display: none;
169 | }
170 |
171 |
172 | #status-container #current-track-info,
173 | #status-container #current-radio-info {
174 | background-image: -moz-linear-gradient(top, rgb(5,68,114) 10%, #021B2E 100%);
175 | background-image: -webkit-linear-gradient(top, rgb(5,68,114) 10%, #021B2E 100%);
176 | border-top: 1px solid rgb(37,85,125);
177 | padding-top: 10px;
178 | padding-left: 5px;
179 | padding-bottom: 10px;
180 | }
181 |
182 | #status-container #current-track-info div,
183 | #status-container #current-track-info img,
184 | #status-container #current-radio-info div,
185 | #status-container #current-radio-info img {
186 | display: inline-block;
187 | vertical-align: top;
188 | }
189 |
190 | #status-container #current-track-info div.info-container,
191 | #status-container #current-radio-info div.info-container {
192 | padding-top: 20px;
193 | padding-bottom: 30px;
194 | width: calc(100% - 220px);
195 | overflow: hidden;
196 | word-wrap: nowrap;
197 | }
198 |
199 | #status-container div.art-container {
200 | perspective: 160px;
201 | width: 160px;
202 | height: 160px;
203 | position: relative;
204 | }
205 |
206 | #status-container div.art-container .mirror {
207 | transform: rotateY(10deg) translateZ(-35px);
208 | /* for firefox edge smoothing */
209 | outline: 1px solid transparent;
210 | }
211 |
212 | /*
213 | #status-container div.art-container .mirror:before {
214 | background: -moz-element(#current-track-art) no-repeat;
215 | content: "";
216 | height: 160px;
217 | width: 160px;
218 | left: 0;
219 | padding: 1px 0;
220 | position: absolute;
221 | top: 120px;
222 | transform: scaleY(-1);
223 |
224 | }
225 | */
226 |
227 | #status-container img#current-track-art {
228 | max-height: 160px;
229 | max-width: 160px;
230 | margin-top: 5px;
231 |
232 | -webkit-box-reflect: below 0
233 | -webkit-gradient(linear, left top, left bottom, from(transparent), color-stop(0.75, transparent), to(rgba(255, 255, 255, 0.5)));
234 |
235 | }
236 |
237 | #status-container #current-track-info div p,
238 | #status-container #current-radio-info div p {
239 | height: 36px;
240 | width: 100%;
241 | }
242 |
243 | #status-container #next-track {
244 | width: 100%;
245 | overflow: hidden;
246 | white-space: nowrap;
247 | }
248 |
249 | #status-container h6 {
250 | color: rgb(4,90,218);
251 | font-weight: normal;
252 | font-size: 100%;
253 | margin: 0;
254 | }
255 |
256 | #status-container h5 {
257 | color: rgb(4,90,218);
258 | font-size: 14px;
259 | margin: 0;
260 | margin-top: 4px;
261 | }
262 |
263 | #status-container p {
264 | margin: 0;
265 | font-size: 100%;
266 | }
267 |
268 | /* rounded containers */
269 | #top-control,
270 | #zone-container,
271 | #status-container,
272 | #music-sources-container
273 | {
274 | border-radius: 5px;
275 | }
276 |
277 | #column-container {
278 | text-align: justify;
279 | height: calc(100% - 105px);
280 | }
281 |
282 | #zone-container,
283 | #status-container,
284 | #music-sources-container {
285 | width: calc(25% - 4px);
286 | display: inline-block;
287 | height: 100%;
288 | height: calc(100vh - 105px);
289 | background: #021B2E;
290 | vertical-align: top;
291 | overflow: hidden;
292 | }
293 |
294 | #zone-container {
295 | width: calc(25% - 4px);
296 | }
297 | #status-container {
298 | width: calc(35% - 4px);
299 | }
300 | #music-sources-container {
301 | width: calc(40% - 4px);
302 | }
303 |
304 | #zone-container h4,
305 | #status-container h4,
306 | #music-sources-container h4 {
307 | font-weight: 600;
308 | font-size: 120%;
309 | padding: 10px;
310 | margin: 0;
311 | border-bottom: 1px solid #000000;
312 | border-radius: 5px 5px 0 0;
313 | }
314 |
315 | #zone-wrapper {
316 | border-top: #012947 solid 1px;
317 | height: calc(100% - 38px);
318 | overflow-y: auto;
319 | }
320 |
321 | #music-sources-container {
322 | position: relative;
323 | background-image: url(../images/s_bloom_20.png), radial-gradient(circle at bottom right, #043B63 0, #021B2E 180px);
324 | background-position: bottom right;
325 | background-repeat: no-repeat;
326 |
327 | }
328 |
329 | #master-volume {
330 | top: 28px;
331 | left: 50px;
332 | position: absolute;
333 | }
334 |
335 | #top-control .volume-bar {
336 | width: 180px;
337 | height: 21px;
338 | border-radius: 15px;
339 | border-top: 1px solid #303030;
340 | border-bottom: 1px solid #707070;
341 | background-image: -moz-linear-gradient(bottom, rgb(81,81,81) 5%, rgb(37,37,37) 95%);
342 | background-image: -webkit-linear-gradient(bottom, rgb(81,81,81) 5%, rgb(37,37,37) 95%);
343 |
344 | }
345 | #top-control .volume-bar img {
346 | margin: -1px 1px;
347 | }
348 |
349 | #top-control #repeat,
350 | #top-control #shuffle,
351 | #top-control #crossfade {
352 | transition: background 0.1s;
353 | }
354 |
355 | #top-control #repeat:active,
356 | #top-control #shuffle:active,
357 | #top-control #crossfade:active {
358 | background: #aaa;
359 | }
360 |
361 | #player-volumes {
362 | position: absolute;
363 | top: 58px;
364 | left: 50px;
365 | padding: 10px;
366 | background: #fff;
367 | z-index: 100;
368 | border-radius: 10px;
369 | border: 3px #666 solid;
370 | opacity: 1;
371 | -webkit-transition: -webkit-opacity 0.3s, -webkit-transform 0.3s;
372 | transition: opacity 0.3s, transform 0.3s;
373 | }
374 |
375 |
376 | #player-volumes h6 {
377 | font-size: 110%;
378 | }
379 | #player-volumes.loading {
380 | visibility: hidden;
381 | -webkit-transform: scale(0);
382 | transform: scale(0);
383 | }
384 |
385 | #player-volumes.hidden {
386 | opacity: 0;
387 | -webkit-transform: scale(0);
388 | transform: scale(0);
389 | }
390 |
391 | #player-volumes .hidden {
392 | display: none;
393 | }
394 |
395 | #player-volumes h6 {
396 | color: #222;
397 | margin: 0;
398 | margin-top: 5px;
399 | text-shadow: none;
400 | margin-bottom: 5px;
401 | }
402 |
403 | #player-volumes .volume-bar {
404 | display: inline-block;
405 | vertical-align: middle;
406 | margin-left: 15px;
407 | position: relative;
408 | }
409 |
410 | #master-mute {
411 | position: absolute;
412 | top: 24px;
413 | left: 10px;
414 | }
415 |
416 | #controls {
417 | height: 44px;
418 | width: 124px;
419 | margin: 7px auto 0;
420 | }
421 |
422 | #position-info {
423 | height: 22px;
424 | width: 440px;
425 | margin: 7px auto 0 auto;
426 | position: relative;
427 | }
428 |
429 | #position-info .left {
430 | position: absolute;
431 | left: 0;
432 | }
433 |
434 | #position-info .right {
435 | position: absolute;
436 | right: 0;
437 | }
438 |
439 | #position-info .content {
440 | margin: 0px 38px 0 38px;
441 | height: 22px;
442 | background-image: url(../images/tc_progress_container_center.png);
443 | font-size: 70%;
444 | color: #666;
445 | text-shadow: #111 0px -1px;
446 | }
447 |
448 | #position-info .content span,
449 | #position-info .content img,
450 | #position-info .content div {
451 | display: inline-block;
452 | vertical-align: middle;
453 | }
454 |
455 | #position-info .content #countup {
456 | margin-left: 8px;
457 | }
458 |
459 | #position-info .content #countdown {
460 | margin-right: 20px;
461 | }
462 |
463 | #drag-area {
464 | padding: 4px;
465 | transform: translateY(-4px) translateX(-4px);
466 | }
467 |
468 | #drag-area:hover #position-bar-scrubber {
469 | background: linear-gradient(to bottom, #4399CC, #086EAA);
470 | box-shadow: 0px 0px 1px 1px #4399CC;
471 | }
472 |
473 | #position-bar-scrubber {
474 | width: 4px;
475 | height: 4px;
476 | border-radius: 2px;
477 | background: linear-gradient(to bottom, rgb(240,240,240), rgb(72,72,72));
478 | -moz-box-sizing: border-box;
479 | -webkit-box-sizing: border-box;
480 | vertical-align: middle;
481 | display: block !important;
482 | }
483 |
484 | #position-bar-scrubber.sliding {
485 | background: linear-gradient(to bottom, rgb(255,255,255), rgb(230,230,230));
486 | box-shadow: 0px 0px 1px 1px white;
487 | }
488 |
489 | #position-bar {
490 | height: 4px;
491 | width: 150px;
492 | border-radius: 3px;
493 | border-top: 1px solid #101010;
494 | border-bottom: 1px solid #606060;
495 | vertical-align: middle;
496 | position: relative;
497 | }
498 |
499 | #zone-wrapper ul {
500 | background-image: -moz-linear-gradient(top, #04304F 0px, #032540 24px);
501 | background-image: -webkit-linear-gradient(top, #04304F 0px, #032540 24px);
502 | margin: 10px;
503 | padding: 10px 10px 10px 46px;
504 | border-radius: 5px;
505 | border-top: 1px solid #054367;
506 | border-right: 1px solid #032B4A;
507 | border-left: 1px solid #032C4B;
508 | border-bottom: 1px solid #032B4A;
509 | position: relative;
510 | }
511 |
512 | #zone-wrapper ul.selected {
513 | background-image: -moz-linear-gradient(top, #07558C 0px, #0A4069 24px);
514 | background-image: -webkit-linear-gradient(top, #07558C 0px, #0A4069 24px);
515 | border-top: 1px solid #086EAA;
516 | border-right: 1px solid #003F6C;
517 | border-left: 1px solid #02375E;
518 | border-bottom: 1px solid #01365D;
519 | }
520 |
521 | #zone-wrapper ul:hover {
522 | background-image: -moz-linear-gradient(top, #096EB6 0px, #0E568D 24px);
523 | background-image: -webkit-linear-gradient(top, #096EB6 0px, #0E568D 24px);
524 | }
525 |
526 |
527 | #zone-wrapper ul li {
528 | list-style: url(../images/wdcr_zm_ic_bedroom.png);
529 | padding-top: 3px;
530 | cursor: pointer;
531 | }
532 |
533 | #zone-wrapper ul li.drag {
534 | outline: 2px #ddd dashed;
535 | cursor: move;
536 | }
537 |
538 | #zone-wrapper ul span {
539 | vertical-align: super;
540 | font-size: 140%;
541 | }
542 |
543 | #favorites-container,
544 | #queue-container {
545 | margin: 0;
546 | padding: 0;
547 | cursor: pointer;
548 | height: calc(100% - 40px);
549 | overflow: auto;
550 | border-top: 1px solid #012947;
551 | }
552 |
553 | #favorites-container li,
554 | #queue-container li {
555 | list-style: none;
556 | margin: 0;
557 | padding: 0;
558 | padding: 1px 0 1px 0;
559 | font-size: 100%;
560 | width: 100%;
561 | transition: all 0.15s;
562 | }
563 |
564 | #favorites-container li:hover,
565 | #queue-container li:hover {
566 | background: rgba(255,255,255,0.05);
567 | outline: none;
568 | }
569 |
570 | #favorites-container li:focus,
571 | #queue-container li:focus {
572 | background: rgba(255,255,255,0.15);
573 | outline: none;
574 | }
575 |
576 |
577 |
578 | #favorites-container li img,
579 | #queue-container li img {
580 | width: 40px;
581 | height: 40px;
582 | vertical-align: top;
583 | margin-right: 3px;
584 | display: inline-block;
585 | }
586 |
587 | #queue-container li div {
588 | display: inline-block;
589 | width: calc(100% - 43px);
590 | }
591 |
592 | #queue-container li div p {
593 | margin: 0;
594 | line-height: 18px;
595 | font-size: 100%;
596 | overflow: hidden;
597 | width: 100%;
598 | white-space: nowrap;
599 | }
600 |
601 | #queue-container li div p.artist {
602 | color: #aaa;
603 | }
604 |
605 | #favorites-container li span {
606 | padding: 3px;
607 | line-height: 40px;
608 | }
609 |
610 | #queue-container {
611 | height: calc(100% - 316px);
612 | }
613 |
--------------------------------------------------------------------------------