├── README.md ├── hacs.json ├── jukebox-card.js └── screenshot.png /README.md: -------------------------------------------------------------------------------- 1 | # Jukebox Card for Home-Assistant 2 | 3 | This is a media player UI for Home-Assistant leveraging the potential of the excellent new 4 | [Lovelace UI.](https://www.home-assistant.io/lovelace/) 5 | 6 | It allows you to configure a set of web radio stations (or possibly other radio media IDs such as spotify), and 7 | play them to media player entities of your choice, like chromecast or spotify connect listeners. 8 | 9 | You can send different media to different players, which makes it usable for multi-room setups: Let your kids listen 10 | to some *Frozen*, while you're Jazzing in the Kitchen. Volume-Level is handled separately, too. 11 | 12 | ## Screenshot 13 | ![alt text](https://github.com/lukx/home-assistant-jukebox/blob/master/screenshot.png?raw=true "See the jukebox in action") 14 | 15 | ## Acknowledgement 16 | Apart from the home-assistant project, I need to say thanks to User [Bob_NL](https://community.home-assistant.io/u/Bob_NL) 17 | who made his evergreen [Chromecast Radio](https://community.home-assistant.io/t/chromecast-radio-with-station-and-player-selection/12732) 18 | available to all of us in the Home-Assistant forums. This jukebox is heavily deriving from the great work of all the 19 | people in the thread. 20 | 21 | ## Usage 22 | ### Installation using HACS 23 | I recommend using [HACS](https://hacs.xyz/) to install and update this integration. As the jukebox card is not yet in the official repositories of HACS, follow these steps to get it running: 24 | 25 | * (Install HACS if you have not already; look into their documentation in the link above to achieve this) 26 | * In your Home Assistant, open the HACS panel 27 | * Click on "Frontend" to see the list of Frontend (or "Lovelace") integrations 28 | * On the top right of your screen, click on the three dots to see "Custom Repositories" 29 | * in the "Custom Repositories" dialogue, paste `https://github.com/lukx/home-assistant-jukebox.git` in the "custom repository URL" box, and select "Lovelace" as the Category. 30 | * Now, in the Frontend Category, search for "Jukebox" and install this module like you would install any other module. 31 | 32 | 33 | ### Configuration 34 | Find stream URLs, e.g. on [Radio-Browser.info](http://www.radio-browser.info/gui/#/) 35 | See this example setting a couple of Web radios to my two chromecast players. 36 | 37 | #### Using lovlace in yaml mode 38 | 39 | *Excerpt of ui-lovelace.yaml* 40 | ``` 41 | resources: 42 | - url: /hacsfiles/home-assistant-jukebox/jukebox-card.js 43 | type: module 44 | views: 45 | - name: Example 46 | cards: 47 | - type: "custom:jukebox-card" 48 | links: 49 | - url: http://streams.greenhost.nl:8080/jazz 50 | name: Concertzender Jazz 51 | - url: http://fs-insidejazz.fast-serv.com:8282/;stream.nsv 52 | name: Inside Jazz 53 | - url: http://stream.srg-ssr.ch/m/rsj/mp3_128 54 | name: Radio Swiss Jazz 55 | - url: http://stream.beachlatinoradio.com:8030/;?d= 56 | name: Beach Latino Radio 57 | - url: http://streams.calmradio.com/api/43/128/stream/;?d= 58 | name: Calm Radio 59 | - url: http://swr-swr1-bw.cast.addradio.de/swr/swr1/bw/mp3/128/stream.mp3 60 | name: SWR 1 61 | - url: http://94.23.252.14:8067/stream 62 | name: Nature Sounds 63 | entities: 64 | - media_player.wuerfel_wohnzimmer 65 | - media_player.wuerfel_kueche 66 | ``` 67 | 68 | #### Using lovelace UI 69 | * Go to the view you want to add the card, switch it to edit mode and click `+ add card` 70 | * Scroll all the way down and select `Manual` 71 | * Paste your config and save 72 | 73 | Example config (note the differances from the above example): 74 | ``` 75 | type: "custom:jukebox-card" 76 | links: 77 | - url: http://streams.greenhost.nl:8080/jazz 78 | name: Concertzender Jazz 79 | - url: http://fs-insidejazz.fast-serv.com:8282/;stream.nsv 80 | name: Inside Jazz 81 | - url: http://stream.srg-ssr.ch/m/rsj/mp3_128 82 | name: Radio Swiss Jazz 83 | - url: http://stream.beachlatinoradio.com:8030/;?d= 84 | name: Beach Latino Radio 85 | - url: http://streams.calmradio.com/api/43/128/stream/;?d= 86 | name: Calm Radio 87 | - url: http://swr-swr1-bw.cast.addradio.de/swr/swr1/bw/mp3/128/stream.mp3 88 | name: SWR 1 89 | - url: http://94.23.252.14:8067/stream 90 | name: Nature Sounds 91 | entities: 92 | - media_player.wuerfel_wohnzimmer 93 | - media_player.wuerfel_kueche 94 | 95 | ``` 96 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Lovelace Jukebox Card", 3 | "render_readme": true, 4 | "filename": "jukebox-card.js" 5 | } 6 | -------------------------------------------------------------------------------- /jukebox-card.js: -------------------------------------------------------------------------------- 1 | class JukeboxCard extends HTMLElement { 2 | set hass(hass) { 3 | if (!this.content) { 4 | this._hassObservers = []; 5 | this.appendChild(getStyle()); 6 | const card = document.createElement('ha-card'); 7 | this.content = document.createElement('div'); 8 | card.appendChild(this.content); 9 | this.appendChild(card); 10 | 11 | this.content.appendChild(this.buildSpeakerSwitches(hass)); 12 | this.content.appendChild(this.buildVolumeSlider()); 13 | this.content.appendChild(this.buildStationList()); 14 | } 15 | 16 | this._hass = hass; 17 | this._hassObservers.forEach(listener => listener(hass)); 18 | } 19 | 20 | get hass() { 21 | return this._hass; 22 | } 23 | 24 | buildSpeakerSwitches(hass) { 25 | this._tabs = document.createElement('paper-tabs'); 26 | this._tabs.setAttribute('scrollable', true); 27 | this._tabs.addEventListener('iron-activate', (e) => this.onSpeakerSelect(e.detail.item.entityId)); 28 | 29 | this.config.entities.forEach(entityId => { 30 | if (!hass.states[entityId]) { 31 | console.log('Jukebox: No State for entity', entityId); 32 | return; 33 | } 34 | this._tabs.appendChild(this.buildSpeakerSwitch(entityId, hass)); 35 | }); 36 | 37 | // automatically activate the first speaker that's playing 38 | const firstPlayingSpeakerIndex = this.findFirstPlayingIndex(hass); 39 | this._selectedSpeaker = this.config.entities[firstPlayingSpeakerIndex]; 40 | this._tabs.setAttribute('selected', firstPlayingSpeakerIndex); 41 | 42 | return this._tabs; 43 | } 44 | 45 | buildStationList() { 46 | this._stationButtons = []; 47 | 48 | const stationList = document.createElement('div'); 49 | stationList.classList.add('station-list'); 50 | 51 | this.config.links.forEach(linkCfg => { 52 | const stationButton = this.buildStationSwitch(linkCfg.name, linkCfg.url) 53 | this._stationButtons.push(stationButton); 54 | stationList.appendChild(stationButton); 55 | }); 56 | 57 | // make sure the update method is notified of a change 58 | this._hassObservers.push(this.updateStationSwitchStates.bind(this)); 59 | 60 | return stationList; 61 | } 62 | 63 | buildVolumeSlider() { 64 | const volumeContainer = document.createElement('div'); 65 | volumeContainer.className = 'volume center horizontal layout'; 66 | 67 | const muteButton = document.createElement('ha-icon-button'); 68 | muteButton.icon = 'hass:volume-high'; 69 | muteButton.isMute = false; 70 | muteButton.addEventListener('click', this.onMuteUnmute.bind(this)); 71 | const mbIcon = document.createElement('ha-icon'); 72 | mbIcon.icon = 'hass:volume-high'; 73 | muteButton.appendChild(mbIcon); 74 | 75 | const slider = document.createElement('ha-slider'); 76 | slider.min = 0; 77 | slider.max = 100; 78 | slider.addEventListener('change', this.onChangeVolumeSlider.bind(this)); 79 | slider.className = 'flex'; 80 | 81 | const stopButton = document.createElement('ha-icon-button') 82 | stopButton.icon = 'hass:stop'; 83 | stopButton.setAttribute('disabled', true); 84 | stopButton.addEventListener('click', this.onStop.bind(this)); 85 | const sbIcon = document.createElement('ha-icon'); 86 | sbIcon.icon = 'hass:stop'; 87 | stopButton.appendChild(sbIcon); 88 | 89 | 90 | this._hassObservers.push(hass => { 91 | if (!this._selectedSpeaker || !hass.states[this._selectedSpeaker]) { 92 | return; 93 | } 94 | const speakerState = hass.states[this._selectedSpeaker].attributes; 95 | 96 | // no speaker level? then hide mute button and volume 97 | if (!speakerState.hasOwnProperty('volume_level')) { 98 | slider.setAttribute('hidden', true); 99 | stopButton.setAttribute('hidden', true) 100 | } else { 101 | slider.removeAttribute('hidden'); 102 | stopButton.removeAttribute('hidden') 103 | } 104 | 105 | if (!speakerState.hasOwnProperty('is_volume_muted')) { 106 | muteButton.setAttribute('hidden', true); 107 | } else { 108 | muteButton.removeAttribute('hidden'); 109 | } 110 | 111 | if (hass.states[this._selectedSpeaker].state === 'playing') { 112 | stopButton.removeAttribute('disabled'); 113 | } else { 114 | stopButton.setAttribute('disabled', true); 115 | } 116 | 117 | slider.value = speakerState.volume_level ? speakerState.volume_level * 100 : 0; 118 | 119 | if (speakerState.is_volume_muted && !slider.disabled) { 120 | slider.disabled = true; 121 | muteButton.icon = 'hass:volume-off'; 122 | muteButton.isMute = true; 123 | } else if (!speakerState.is_volume_muted && slider.disabled) { 124 | slider.disabled = false; 125 | muteButton.icon = 'hass:volume-high'; 126 | muteButton.isMute = false; 127 | } 128 | }); 129 | 130 | volumeContainer.appendChild(muteButton); 131 | volumeContainer.appendChild(slider); 132 | volumeContainer.appendChild(stopButton); 133 | return volumeContainer; 134 | } 135 | 136 | onSpeakerSelect(entityId) { 137 | this._selectedSpeaker = entityId; 138 | this._hassObservers.forEach(listener => listener(this.hass)); 139 | } 140 | 141 | onChangeVolumeSlider(e) { 142 | const volPercentage = parseFloat(e.currentTarget.value); 143 | const vol = (volPercentage > 0 ? volPercentage / 100 : 0); 144 | this.setVolume(vol); 145 | } 146 | 147 | onMuteUnmute(e) { 148 | this.hass.callService('media_player', 'volume_mute', { 149 | entity_id: this._selectedSpeaker, 150 | is_volume_muted: !e.currentTarget.isMute 151 | }); 152 | } 153 | 154 | onStop(e) { 155 | this.hass.callService('media_player', 'media_stop', { 156 | entity_id: this._selectedSpeaker 157 | }); 158 | } 159 | 160 | updateStationSwitchStates(hass) { 161 | let playingUrl = null; 162 | const selectedSpeaker = this._selectedSpeaker; 163 | 164 | if (hass.states[selectedSpeaker] && hass.states[selectedSpeaker].state === 'playing') { 165 | playingUrl = hass.states[selectedSpeaker].attributes.media_content_id; 166 | } 167 | 168 | this._stationButtons.forEach(stationSwitch => { 169 | if (stationSwitch.hasAttribute('raised') && stationSwitch.stationUrl !== playingUrl) { 170 | stationSwitch.removeAttribute('raised'); 171 | return; 172 | } 173 | if (!stationSwitch.hasAttribute('raised') && stationSwitch.stationUrl === playingUrl) { 174 | stationSwitch.setAttribute('raised', true); 175 | } 176 | }) 177 | } 178 | 179 | buildStationSwitch(name, url) { 180 | const btn = document.createElement('mwc-button'); 181 | btn.stationUrl = url; 182 | btn.className = 'juke-toggle'; 183 | btn.innerText = name; 184 | btn.addEventListener('click', this.onStationSelect.bind(this)); 185 | return btn; 186 | } 187 | 188 | onStationSelect(e) { 189 | this.hass.callService('media_player', 'play_media', { 190 | entity_id: this._selectedSpeaker, 191 | media_content_id: e.currentTarget.stationUrl, 192 | media_content_type: 'audio/mp4' 193 | }); 194 | } 195 | 196 | setVolume(value) { 197 | this.hass.callService('media_player', 'volume_set', { 198 | entity_id: this._selectedSpeaker, 199 | volume_level: value 200 | }); 201 | } 202 | 203 | /*** 204 | * returns the numeric index of the first entity in a "Playing" state, or 0 (first index). 205 | * 206 | * @param hass 207 | * @returns {number} 208 | * @private 209 | */ 210 | findFirstPlayingIndex(hass) { 211 | return Math.max(0, this.config.entities.findIndex(entityId => { 212 | return hass.states[entityId] && hass.states[entityId].state === 'playing'; 213 | })); 214 | } 215 | 216 | buildSpeakerSwitch(entityId, hass) { 217 | const entity = hass.states[entityId]; 218 | 219 | const btn = document.createElement('paper-tab'); 220 | btn.entityId = entityId; 221 | btn.innerText = hass.states[entityId].attributes.friendly_name; 222 | return btn; 223 | } 224 | 225 | setConfig(config) { 226 | if (!config.entities) { 227 | throw new Error('You need to define your media player entities'); 228 | } 229 | this.config = config; 230 | } 231 | 232 | getCardSize() { 233 | return 3; 234 | } 235 | } 236 | 237 | function getStyle() { 238 | const frag = document.createDocumentFragment(); 239 | 240 | const included = document.createElement('style'); 241 | included.setAttribute('include', 'iron-flex iron-flex-alignment'); 242 | 243 | const ownStyle = document.createElement('style'); 244 | ownStyle.innerHTML = ` 245 | .layout.horizontal, .layout.vertical { 246 | display: -ms-flexbox; 247 | display: -webkit-flex; 248 | display: flex; 249 | } 250 | 251 | .layout.horizontal { 252 | -ms-flex-direction: row; 253 | -webkit-flex-direction: row; 254 | flex-direction: row; 255 | } 256 | 257 | .layout.center, .layout.center-center { 258 | -ms-flex-align: center; 259 | -webkit-align-items: center; 260 | align-items: center; 261 | } 262 | 263 | .flex { 264 | ms-flex: 1 1 0.000000001px; 265 | -webkit-flex: 1; 266 | flex: 1; 267 | -webkit-flex-basis: 0.000000001px; 268 | flex-basis: 0.000000001px; 269 | } 270 | 271 | [hidden] { 272 | display: none !important; 273 | } 274 | 275 | .volume { 276 | padding: 10px 20px; 277 | } 278 | 279 | mwc-button.juke-toggle { 280 | --mdc-theme-primary: var(--primary-text-color); 281 | } 282 | 283 | mwc-button.juke-toggle[raised] { 284 | --mdc-theme-primary: var(--primary-color); 285 | background-color: var(--primary-color); 286 | color: var(--text-primary-color); 287 | } 288 | 289 | paper-tabs { 290 | background-color: var(--primary-color); 291 | color: var(--text-primary-color); 292 | --paper-tabs-selection-bar-color: var(--text-primary-color, #FFF); 293 | } 294 | 295 | `; 296 | 297 | frag.appendChild(included); 298 | frag.appendChild(ownStyle); 299 | return frag; 300 | } 301 | 302 | customElements.define('jukebox-card', JukeboxCard); 303 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lukx/home-assistant-jukebox/d0aa8851d90f820e224416efcf9096cdbec45ee8/screenshot.png --------------------------------------------------------------------------------