├── 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 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /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 |
15 | 16 |
17 |
18 | 19 |
20 |
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 |
18 |

ROOMS

19 |
20 |
21 | 22 |
23 |

FAVORITES

24 | 25 |
26 | 27 |
28 | 29 |
30 | 31 |
32 |

33 |

34 |

35 |
36 |

Next:

37 |
38 |
39 | 40 | 41 | 42 |
43 |
44 |
45 |
46 |
47 |
48 | 49 |
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 | 18 | 20 | 41 | 48 | 52 | 53 | 55 | 56 | 58 | image/svg+xml 59 | 61 | 62 | 63 | 64 | 65 | 69 | 73 | 77 | 81 | 82 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /static/svg/play.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 18 | 20 | 39 | 46 | 47 | 49 | 50 | 52 | image/svg+xml 53 | 55 | 56 | 57 | 58 | 59 | 63 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /static/svg/mute_on.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 18 | 37 | 39 | 41 | 42 | 44 | image/svg+xml 45 | 47 | 48 | 49 | 50 | 51 | 55 | 72 | 79 | 88 | 89 | 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 | 18 | 37 | 39 | 41 | 42 | 44 | image/svg+xml 45 | 47 | 48 | 49 | 50 | 51 | 55 | 72 | 79 | 85 | 91 | 92 | 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 | 18 | 20 | 40 | 47 | 48 | 50 | 51 | 53 | image/svg+xml 54 | 56 | 57 | 58 | 59 | 60 | 64 | 82 | 89 | 107 | 108 | 109 | -------------------------------------------------------------------------------- /static/svg/next.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 18 | 20 | 40 | 47 | 48 | 50 | 51 | 53 | image/svg+xml 54 | 56 | 57 | 58 | 59 | 60 | 64 | 82 | 90 | 108 | 109 | 110 | -------------------------------------------------------------------------------- /static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 11 | Sonos Web Controller 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 | 0:00 39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | -0:00 47 |
48 |
49 | 60 |
61 |
62 |
63 |

ROOMS

64 |
65 |
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 |
    98 |
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 | --------------------------------------------------------------------------------