├── .babelrcs ├── .eslintrc.js ├── .github └── FUNDING.yml ├── .gitignore ├── .nvmrc ├── .prettierrc.js ├── .travis.yml ├── LICENSE ├── README.md ├── info.md ├── package-lock.json ├── package.json ├── rollup.config.dev.js ├── rollup.config.js ├── src ├── components │ ├── button.ts │ ├── checkbox.ts │ ├── dropdown.js │ ├── groupItem.ts │ ├── groupList.ts │ ├── mediaControls.js │ ├── powerstrip.js │ ├── progress.js │ ├── shortcuts.js │ ├── soundMenu.ts │ ├── sourceMenu.ts │ └── tts.js ├── config │ ├── config.ts │ └── types.ts ├── const.ts ├── editor.js ├── ensureComponents.ts ├── main.ts ├── model.ts ├── sharedStyle.ts ├── style.ts ├── translations.ts ├── types.ts └── utils │ ├── colorGenerator.js │ ├── getProgress.ts │ ├── handleClick.js │ ├── misc.ts │ └── translation.ts └── tsconfig.json /.babelrcs: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "modules": false, 7 | "targets": { 8 | "esmodules": true 9 | } 10 | } 11 | ], 12 | ["minify"] 13 | ], 14 | "comments": false, 15 | "plugins": [ 16 | [ 17 | "@babel/plugin-proposal-decorators", 18 | { "legacy": true } 19 | ], 20 | [ 21 | "@babel/plugin-proposal-class-properties", 22 | { "loose": true } 23 | ], 24 | ["@babel/plugin-transform-template-literals"], 25 | ["iife-wrap"] 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: "@typescript-eslint/parser", // Specifies the ESLint parser 3 | parserOptions: { 4 | ecmaVersion: 2020, // Allows for the parsing of modern ECMAScript features 5 | sourceType: "module" // Allows for the use of imports 6 | }, 7 | extends: [ 8 | "plugin:@typescript-eslint/recommended" // Uses the recommended rules from the @typescript-eslint/eslint-plugin 9 | ], 10 | rules: { 11 | // Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs 12 | // e.g. "@typescript-eslint/explicit-function-return-type": "off", 13 | } 14 | }; -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: ['https://www.paypal.me/kalkih'] 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /dist/ 3 | .DS_Store 4 | .vscode 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v16.14.1 -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | trailingComma: "all", 4 | singleQuote: true, 5 | printWidth: 120, 6 | tabWidth: 2, 7 | }; 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "16.14.1" 4 | script: 5 | - npm run lint 6 | - npm run build -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-2019 Karl Kihlström 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, 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, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mini Media Player 2 | 3 | [![](https://img.shields.io/github/release/kalkih/mini-media-player.svg?style=flat-square)](https://github.com/kalkih/mini-media-player/releases/latest) 4 | [![](https://img.shields.io/travis/com/kalkih/mini-media-player?style=flat-square)](https://travis-ci.org/kalkih/mini-media-player) 5 | 6 | A minimalistic yet customizable media player card for [Home Assistant](https://github.com/home-assistant/home-assistant) Lovelace UI. 7 | 8 | Inspired by [Custom UI: Mini media player](https://community.home-assistant.io/t/custom-ui-mini-media-player/40135) and [custom-lovelace](https://github.com/ciotlosm/custom-lovelace). 9 | 10 | ![Preview Image](https://user-images.githubusercontent.com/457678/47517460-9282d600-d888-11e8-9705-cf9ec3698c3c.png) 11 | 12 | 13 | ## Installation 14 | 15 | ### HACS (recommended) 16 | 17 | This card is available in [HACS](https://github.com/hacs/integration) (Home Assistant Community Store). 18 | 19 | 1. Install HACS if you don't have it already 20 | 2. Open HACS in Home Assistant 21 | 3. Go to "Frontend" section 22 | 4. Click button with "+" icon 23 | 5. Search for "Mini Media Player" 24 | 25 | ### Manual install 26 | 27 | #### UI mode 28 | 29 | 1. Download and copy `mini-media-player-bundle.js` from the [latest release](https://github.com/kalkih/mini-media-player/releases/latest) into your `config/www` directory. 30 | 2. Go to Sidebar -> Settings -> Dashboards -> Menu (top right corner) -> Resources. 31 | 3. Click on `+ ADD RESOURCE`. 32 | 4. Type `/local/mini-media-player-bundle.js?v=1.16.9` below URL. 33 | 5. Choose `JavaScript Module` below Resource Type. 34 | 6. Accept. 35 | 36 | #### YAML mode 37 | 38 | 1. Download and copy `mini-media-player-bundle.js` from the [latest release](https://github.com/kalkih/mini-media-player/releases/latest) into your `config/www` directory. 39 | 2. Add a reference to `mini-media-player-bundle.js` inside your `configuration.yaml` or through the Home Assistant UI from the resource tab. 40 | 41 | ```yaml 42 | lovelace: 43 | resources: 44 | - url: /local/mini-media-player-bundle.js?v=1.16.9 45 | type: module 46 | ``` 47 | 48 | *To update the card to a new version after manual installation, update `mini-media-player-bundle.js` file from [latest release](https://github.com/kalkih/mini-media-player/releases/latest) and edit version of the card in your resources. You may need to empty the browsers cache if you have problems loading the updated card.* 49 | 50 | ## Using the card 51 | 52 | ### Options 53 | 54 | #### Card options 55 | | Name | Type | Default | Since | Description | 56 | |------|------|---------|-------|-------------| 57 | | type | string | **required** | v0.1 | `custom:mini-media-player` 58 | | entity | string | **required** | v0.1 | An entity_id from an entity within the `media_player` domain. 59 | | name | string | optional | v0.6 | Override the entities friendly name. 60 | | icon | string | optional | v0.1 | Specify a custom icon from any of the available mdi icons. 61 | | icon_image | string | optional | v1.16.2 | Override icon with an image url 62 | | tap_action | [action object](#action-object-options) | true | v0.7.0 | Action on click/tap. 63 | | group | boolean | optional | v0.1 | Removes paddings, background color and box-shadow. 64 | | hide | object | optional | v1.0.0 | Manage visible UI elements, see [hide object](#hide-object) for available options. 65 | | artwork | string | default | v0.4 | `cover` to display current artwork in the card background, `full-cover` to display full artwork, `material` for alternate artwork display with dynamic colors, `none` to hide artwork, `full-cover-fit` for full cover without cropping. 66 | | tts | object | optional | v1.0.0 | Show Text-To-Speech input, see [TTS object](#tts-object) for available options. 67 | | source | string | optional | v0.7 | Change source select appearance, `icon` for just an icon, `full` for the full source name. 68 | | sound_mode | string | optional | v1.1.2 | Change sound mode select appearance, `icon` for just an icon, `full` for the full sound mode name. 69 | | info | string | optional | v1.0.0 | Change how the media information is displayed, `short` to limit media information to one row, `scroll` to scroll overflowing media info. 70 | | volume_stateless | boolean | false | v0.6 | Swap out the volume slider for volume up & down buttons. 71 | | volume_step | number | optional | v1.9.0 | Change the volume step size of the volume buttons and the volume slider (number between 1 - 100)[1](#option_foot1). 72 | | max_volume | number | optional | v0.8.2 | Specify the max vol limit of the volume slider (number between 1 - 100). 73 | | min_volume | number | optional | v1.1.2 | Specify the min vol limit of the volume slider (number between 1 - 100). 74 | | replace_mute | string | optional | v0.9.8 | Replace the mute button, available options are `play_pause` (previously `play`), `stop`, `play_stop`, `next`. 75 | | jump_amount | number | 10 | v0.14.0 | Configure amount of seconds to skip/rewind for jump buttons. 76 | | toggle_power | boolean | true | v0.8.9 | Set to `false` to change the power button behaviour to `media_player.turn_on`/`media_player.turn_off`. 77 | | idle_view | object | optional | v1.0.0 | Display a less cluttered view when idle, See [Idle object](#idle-object) for available options. 78 | | background | string | optional | v0.8.6 | Background image, specify the image url `"/local/background-img.png"` e.g. 79 | | speaker_group | object | optional | v1.0.0 | Speaker group management/multiroom, see [Speaker group object](#speaker-group-object) for available options. 80 | | shortcuts | object | optional | v1.0.0 | Media shortcuts in a list or as buttons, see [Shortcut object](#shortcuts-object) for available options. 81 | | scale | number | optional | v1.5.0 | UI scale modifier, default is `1`. 82 | 83 | 1 Only supported on entities with `volume_level` attribute. 84 | 85 | #### Idle object 86 | | Name | Type | Default | Description | 87 | |------|------|---------|-------------| 88 | | when_idle | boolean | optional | Render the idle view when player state is `idle`. 89 | | when_paused | boolean | optional | Render the idle view when player state is `paused` 90 | | when_standby | boolean | optional | Render the idle view when player state is `standby` 91 | | after | string | optional | Specify a number (minutes) after which the card renders as idle *(only supported on platforms exposing `media_position_updated_at`)*. 92 | 93 | #### TTS object 94 | | Name | Type | Default | Description | 95 | |------|------|---------|-------------| 96 | | platform | string | **required** | Specify [TTS platform](https://www.home-assistant.io/components/tts/), e.g. `google_translate` or `amazon_polly`, `cloud` for Nabu Casa, `alexa`[1](#tts_foot1) for ["Alexa as Media Player"](https://community.home-assistant.io/t/echo-devices-alexa-as-media-player-testers-needed/58639), `ga`[2](#tts_foot2)[3](#tts_foot3) for use with [Google Assistant Webserver](https://community.home-assistant.io/t/community-hass-io-add-on-google-assistant-webserver-broadcast-messages-without-interrupting-music/37274) or [Assistant Relay](https://github.com/greghesp/assistant-relay), `sonos`[2](#tts_foot2) for use with modified [sonos_say script](https://github.com/kalkih/mini-media-player/issues/86#issuecomment-465541825), `webos`[4](#tts_foot4), `service`[5](#tts_foot5). 97 | | language | string | optional | The output language. 98 | | entity_id | string/list | optional | The *entity_id* of the desired output entity or a list of *entity_id's*, can also be `all` to broadcast to all entities or `group` to target currently grouped speakers. 99 | | volume | float | optional | Volume level of tts output (0 - 1), only supported by platform `sonos`. 100 | | type | string | optional | `tts`, `announce` or `push`, defaults to `tts`, only supported by platform `alexa`, more info [here](https://github.com/custom-components/alexa_media_player/wiki/Configuration%3A-Notification-Component). 101 | | data | object | optional | Any additional data to pass with the notify command. 102 | 103 | 1 Does not support the `language` option. 104 | 105 | 2 Does not support `language` & `entity_id` options. 106 | 107 | 3 Requires a custom notify service named `ga_broadcast`, see example below. 108 | 109 | ```yaml 110 | # configuration.yaml 111 | notify: 112 | - name: ga_broadcast 113 | platform: rest 114 | resource: http://[xxx.x.x.xxx]:5000/broadcast_message 115 | ``` 116 | 117 | 4 Requires the card entity name to match the notify service name, if they don't match please specify the notify service name in the `entity_id` option. 118 | 119 | 5 Specify `service` & `service_data` under the `data` option, specify `message_field` to use `message` for the service. 120 | 121 | ```yaml 122 | type: custom:mini-media-player 123 | entity: media_player.xiaoai_speaker 124 | tts: 125 | platform: service 126 | data: 127 | service: xiaomi_miot.intelligent_speaker 128 | service_data: 129 | execute: true 130 | silent: true 131 | message_field: text 132 | ``` 133 | 134 | #### Speaker group object 135 | See [Speaker group management](#speaker-group-management) for example usage. 136 | 137 | **Supported platforms** 138 | - sonos 139 | - soundtouch 140 | - musiccast 141 | - squeezebox[1](#speaker_foot1) 142 | - bluesound[1](#speaker_foot1) 143 | - snapcast[1](#speaker_foot1) 144 | - linkplay[2](#speaker_foot2) 145 | - media_player[3](#speaker_foot3) 146 | - heos 147 | 148 | | Name | Type | Default | Description | 149 | |------|------|---------|-------------| 150 | | entities | list | **required** | A list containing [speaker entities](#speaker-entity-object) of one of supported platforms, to enable group management of those speakers. 151 | | platform | string | 'sonos' | Any supported multiroom platform e.g. `sonos`, `soundtouch`, `bluesound`, see **supported platforms** above. 152 | | sync_volume | boolean | optional | Keep volume Synchronize between grouped speakers. 153 | | expanded | boolean | optional | Make the speaker group list expanded by default. 154 | | show_group_count | boolean | true | Have the number of grouped speakers displayed (if any) in the card name. 155 | | icon | string | optional | Override default group button icon *(any mdi icon)*. 156 | | group_mgmt_entity | string | optional | Override the player entity for the group management (Useful if you use a universal media_player as your entity but still want to use the grouping feature) 157 | | supports_master | boolean | optional | Set to false if your multiroom system does not define one of the media players as master. Defaults to `true` and has not to be set to false for `squeezebox` for backward compatibility. 158 | 159 | 1 All features are not yet supported. 160 | 161 | 2 Requires [custom component](https://github.com/nagyrobi/home-assistant-custom-components-linkplay#multiroom) for sound devices based on Linkplay chipset, available in HACS. 162 | 163 | 3 HomeAssistant added join/unjoin services to the media_player. Future official integrations will implement these services (which are slightly different from the ones, which are already supported by this card) instead of implementing them in their own domain. 164 | 165 | #### Speaker entity object 166 | | Name | Type | Default | Description | 167 | |------|------|---------|-------------| 168 | | entity_id | string | **required** | The *entity_id* for the speaker entity. 169 | | name | string | **required** | A display name. 170 | | volume_offset | number | optional | Volume offset *(0-100)* when used with `sync_volume`. 171 | 172 | #### Shortcuts object 173 | See [card with media shortcuts](#card-with-media-shortcuts) for example usage. 174 | 175 | | Name | Type | Default | Description | 176 | |------|------|---------|-------------| 177 | | list | list | optional | A list of [shortcut items](#shortcut-item-object) to be presented as a list, see shortcut item object. 178 | | buttons | list | optional | A list of [shortcut items](#shortcut-item-object) to be presented as buttons. 179 | | hide_when_off | boolean | false | Hide the shortcuts while the entity is off. 180 | | columns | integer (1-6) | 2 | Specify the max number of buttons per row. 181 | | column_height | number | optional | Specify the column height in pixels. 182 | | label | string | `shortcuts...` | Specify a custom default label for the shortcut dropdown. 183 | | attribute | string | optional | Specify any attribute exposed by the media player entity. The attribute value (if exists) is compared with shortcut `id`'s to distinguish selected/active shortcut[1](#shortcuts_foot1). 184 | | align_text | string | optional | Specify alignment of button icon/text `left`, `right`, `center`. 185 | 186 | 1 Examples, `source` for active source or `sound_mode` for active sound mode. 187 | 188 | #### Shortcut item object 189 | | Name | Type | Default | Description | 190 | |------|------|---------|-------------| 191 | | name | string | optional | A display name. 192 | | icon | string | optional | A display icon *(any mdi icon)*. 193 | | image | string | optional | A display image. 194 | | cover | string | optional | A cover image (only supported for button shortcuts). 195 | | type | string | **required** | Type of shortcut. A media type: `music`, `tvshow`, `video`, `episode`, `channel`, `playlist` e.g. or an action type: `source`, `sound_mode`, `script` or `service`. 196 | | id | string | **required** | The media identifier. The format of this is component dependent. For example, you can provide URLs to Sonos & Cast but only a playlist ID to iTunes & Spotify. A source/(sound mode) name can also be specified to change source/(sound mode), use together with type `source`/`sound_mode`. If type `script` specify the script name here or `service` specify the `.`. 197 | | data | list | optional | Extra service payload[1](#shortcut_foot1). 198 | 199 | 1 Only compatible with `script` & `service` shortcuts, useful for sending variables to script. 200 | 201 | #### Action object options 202 | | Name | Type | Default | Options | Description | 203 | |------|:----:|:-------:|:-----------:|-------------| 204 | | action | string | `more-info` | `more-info` / `navigate` / `call-service` / `url` / `fire-dom-event` / `none` | Action to perform. 205 | | entity | string | | Any entity id | Override default entity of `more-info`, when `action` is defined as `more-info`. 206 | | service | string | | Any service | Service to call (e.g. `media_player.toggle`) when `action` is defined as `call-service`. 207 | | service_data | object | | Any service data | Service data to include with the service call (e.g. `entity_id: media_player.office`). 208 | | navigation_path | string | | Any path | Path to navigate to (e.g. `/lovelace/0/`) when `action` is defined as `navigate`. 209 | | url | string | | Any URL | URL to open when `action` is defined as `url`. 210 | | new_tab | boolean | `false` | `true` / `false` | Open URL in new tab when `action` is defined as `url`. 211 | | haptic | string | | `success`, `warning`, `failure`, `light`, `medium`, `heavy`, `selection` | Haptic feedback for the IOS app. 212 | 213 | #### Hide object 214 | | Name | Type | Default | Description | 215 | |------|------|---------|-------------| 216 | | name | boolean | false | The name. 217 | | icon | boolean | false | The entity icon. 218 | | info | boolean | false | The media information. 219 | | power | boolean | false | The power button. 220 | | source | boolean | false | The source select. 221 | | sound_mode | boolean | true | The sound_mode select. 222 | | group_button | boolean | false | The group button. 223 | | controls | boolean | false | The media playback controls. 224 | | prev | boolean | false | The "previous" playback control button. 225 | | next | boolean | false | The "next" playback control button. 226 | | play_pause | boolean | false | The play/pause button in media playback controls. 227 | | play_stop | boolean | true | The play/stop button in media playback controls. 228 | | jump | boolean | true | The jump backwards/forwards buttons (entity needs to support progress). 229 | | volume | boolean | false | The volume controls. 230 | | volume_level | boolean | true | The current volume level in percentage. 231 | | mute | boolean | false | The mute button. 232 | | progress | boolean | false | The progress bar. 233 | | runtime | boolean | true | The media runtime indicators. 234 | | runtime_remaining | boolean | true | The media remaining runtime (requires `runtime` option to be visible). 235 | | artwork_border | boolean | true | The border of the `default` artwork picture. 236 | | power_state | boolean | true | Dynamic color of the power button to indicate on/off. 237 | | icon_state | boolean | true | Dynamic color of the entity icon to indicate entity state. 238 | | shuffle | boolean | true | The shuffle button (only for players with `shuffle_set` support). 239 | | repeat | boolean | true | The repeat button (only for players with `repeat_set` support). 240 | | state_label | boolean | false | State labels such as `Unavailable` & `Idle`. 241 | 242 | 243 | ### Theme variables 244 | The following variables are available and can be set in your theme to change the appearence of the card. 245 | Can be specified by color name, hexadecimal, rgb, rgba, hsl, hsla, basically anything supported by CSS. 246 | 247 | | name | Default | Description | 248 | |------|---------|-------------| 249 | | mini-media-player-base-color | var(--primary-text-color) & var(--paper-item-icon-color) | The color of base text & buttons 250 | | mini-media-player-accent-color | var(--accent-color) | The accent color of UI elements 251 | | mini-media-player-icon-color | --mini-media-player-base-color, var(--paper-item-icon-color, #44739e) | The color for icons 252 | | mini-media-player-button-color | rgba(255,255,255,0.25) | The background color of shortcut and group buttons. 253 | | mini-media-player-overlay-color | rgba(0,0,0,0.5) | The color of the background overlay 254 | | mini-media-player-overlay-color-stop | 25% | The gradient stop of the background overlay 255 | | mini-media-player-overlay-base-color | white | The color of base text, icons & buttons while artwork cover is present 256 | | mini-media-player-overlay-accent-color | white | The accent color of UI elements while artwork cover is present 257 | | mini-media-player-media-cover-info-color | white | Color of the media information text while artwork cover is present 258 | | mini-media-player-background-opacity | 1 | Opacity of the background 259 | | mini-media-player-artwork-opacity | 1 | Opacity of cover artwork 260 | | mini-media-player-progress-height | 6px | Progressbar height 261 | | mini-media-player-scale | 1 | Scale of the card 262 | | mini-media-player-name-font-weight | 400 | Font weight of the entity name 263 | 264 | 265 | ### Example usage 266 | 267 | #### Basic card 268 | Basic card example 269 | 270 | ```yaml 271 | type: custom:mini-media-player 272 | entity: media_player.kitchen_speakers 273 | ``` 274 | 275 | #### Compact card 276 | Setting either `volume` and/or `controls` to `true` in the `hide` option object will render the player as a single row. 277 | 278 | Compact card example 279 | 280 | ```yaml 281 | type: custom:mini-media-player 282 | entity: media_player.example 283 | icon: mdi:spotify 284 | artwork: cover 285 | hide: 286 | volume: true 287 | source: true 288 | power_state: false 289 | ``` 290 | 291 | #### Card with media shortcuts 292 | You can specify media shortcuts through the `shortcuts` option, either as a list or as buttons or why not both? 293 | 294 | Card with media shortcuts example 295 | 296 | ```yaml 297 | - entity: media_player.spotify 298 | type: custom:mini-media-player 299 | artwork: cover 300 | source: icon 301 | hide: 302 | volume: true 303 | shortcuts: 304 | columns: 4 # Max buttons per row 305 | buttons: 306 | # Start predefined playlist 307 | - icon: mdi:cat 308 | type: playlist 309 | id: spotify:user:XXXXXXX:playlist:37i9dQZF1DZ06evO2O09Hg # Where XXXXXXX is your User ID 310 | # Change the source to bathroom 311 | - icon: mdi:dog 312 | type: source 313 | id: Bathroom 314 | # Trigger script 315 | - icon: mdi:dog 316 | type: script 317 | id: script.script_name 318 | # Trigger custom service 319 | - name: Crooners Playlist 320 | type: service 321 | id: spotcast.start 322 | data: 323 | entity_id: media_player.googlehome1234 324 | uri: spotify:playlist:37i9dQZF1DX9XiAcF7t1s5 325 | 326 | ... # etc. 327 | ``` 328 | **Tip**: 329 | If you don't have Sonos, but want just a bit more control over playlists and so, a simple solution is to use the `type: service`-option, to trigger the`spotcast.start`-service. 330 | 331 | Remember to add the [required data](https://github.com/fondberg/spotcast#call-the-service), for spotcast to work. Also, kindly note that the [spotcast](https://github.com/fondberg/spotcast) custom component is required, for this to work. It's available in HACS. 332 | 333 | #### Grouped cards 334 | Set the `group` option to `true` when nesting mini media player cards inside other cards that already have margins/paddings. 335 | 336 | Grouped cards example 337 | 338 | ```yaml 339 | type: entities 340 | entities: 341 | - type: custom:mini-media-player 342 | entity: media_player.multiroom_player 343 | group: true 344 | source: icon 345 | info: short 346 | hide: 347 | volume: true 348 | power: true 349 | - type: custom:mini-media-player 350 | entity: media_player.kitchen_speakers 351 | group: true 352 | hide: 353 | controls: true 354 | - type: custom:mini-media-player 355 | entity: media_player.bathroom_speakers 356 | group: true 357 | hide: 358 | controls: true 359 | - type: custom:mini-media-player 360 | entity: media_player.bedroom_speakers 361 | group: true 362 | hide: 363 | controls: true 364 | - type: custom:mini-media-player 365 | entity: media_player.patio_speakers 366 | group: true 367 | hide: 368 | controls: true 369 | ``` 370 | 371 | #### Stacked cards 372 | By using vertical and horizontal stacks, you can achieve many different setups. 373 | 374 | Stacked cards example 375 | 376 | ```yaml 377 | - type: horizontal-stack 378 | cards: 379 | - entity: media_player.tv_livingroom 380 | type: custom:mini-media-player 381 | info: short 382 | artwork: cover 383 | hide: 384 | mute: true 385 | icon: true 386 | power_state: false 387 | - entity: media_player.tv_bedroom 388 | type: custom:mini-media-player 389 | info: short 390 | artwork: cover 391 | hide: 392 | mute: true 393 | icon: true 394 | power_state: false 395 | - type: horizontal-stack 396 | cards: 397 | - entity: media_player.cc_patio 398 | type: custom:mini-media-player 399 | hide: 400 | icon: true 401 | - entity: media_player.cc_kitchen 402 | type: custom:mini-media-player 403 | hide: 404 | icon: true 405 | - entity: media_player.cc_bath 406 | type: custom:mini-media-player 407 | hide: 408 | icon: true 409 | ``` 410 | 411 | #### Speaker group management 412 | Specify all your speaker entities in a list under the option `speaker_group` -> `entities`. They obviously need to be of the same platform. 413 | 414 | * The card does only allow changes to be made to groups where the entity of the card is the coordinator/master speaker. 415 | * Checking a speakers in the list will make it join the group of the card entity. (*`media_player.sonos_office`* in the example below). 416 | * Unchecking a speaker in the list will remove it from any group it's a part of. 417 | 418 | sonos group management example 419 | 420 | ```yaml 421 | type: custom:mini-media-player 422 | entity: media_player.sonos_office 423 | hide: 424 | power: true 425 | icon: true 426 | source: true 427 | speaker_group: 428 | platform: sonos 429 | show_group_count: true 430 | entities: 431 | - entity_id: media_player.sonos_office 432 | name: Sonos Office 433 | - entity_id: media_player.sonos_livingroom 434 | name: Sonos Livingroom 435 | - entity_id: media_player.sonos_kitchen 436 | name: Sonos Kitchen 437 | - entity_id: media_player.sonos_bathroom 438 | name: Sonos Bathroom 439 | - entity_id: media_player.sonos_bedroom 440 | name: Sonos Bedroom 441 | ``` 442 | 443 | If you are planning to use the `speaker_group` option in several cards, creating a separate yaml file for the list is highly recommended, this will result in a less cluttered `ui-lovelace.yaml` and also make the list easier to manage and maintain. 444 | You then simply reference the file containing the list using `entities: !include filename.yaml` for every occurrence of `entities` in your `ui-lovelace.yaml`. 445 | 446 | This is however only possible when you have lovelace mode set to yaml in your HA configuration, see [Lovelace YAML mode](https://www.home-assistant.io/lovelace/yaml-mode/) for more info. 447 | 448 | ## Development 449 | *If you plan to contribute back to this repo, please fork & create the PR against the [dev](https://github.com/kalkih/mini-media-player/tree/dev) branch.* 450 | 451 | **Clone this repository into your `config/www` folder using git.** 452 | 453 | ```console 454 | $ git clone https://github.com/kalkih/mini-media-player.git 455 | ``` 456 | 457 | **Add a reference to the card in your `ui-lovelace.yaml`.** 458 | 459 | ```yaml 460 | resources: 461 | - url: /local/mini-media-player/dist/mini-media-player-bundle.js 462 | type: module 463 | ``` 464 | 465 | ### Instructions 466 | 467 | *Requires `nodejs` & `npm`* 468 | 469 | 1. Move into the `mini-media-player` repo, checkout the *dev* branch & install dependencies. 470 | ```console 471 | $ cd mini-media-player && git checkout dev && npm install 472 | ``` 473 | 474 | 2. Make changes to the source 475 | 476 | 3. Build the source by running 477 | ```console 478 | $ npm run build 479 | ``` 480 | 481 | 4. Refresh the browser to see changes 482 | 483 | *Make sure cache is cleared or disabled* 484 | 485 | 5. *(Optional)* Watch the source and automatically rebuild on save 486 | ```console 487 | $ npm run watch 488 | ``` 489 | 490 | *The new `mini-media-player-bundle.js` will be build and ready inside `/dist`.* 491 | 492 | 493 | ## Getting errors? 494 | Make sure you have `javascript_version: latest` in your `configuration.yaml` under `frontend:`. 495 | 496 | Make sure you have the latest version of `mini-media-player-bundle.js`. 497 | 498 | If you have issues after updating the card, try clearing your browsers cache or restart Home Assistant. 499 | 500 | If you are getting "Custom element doesn't exist: mini-media-player" or running older browsers try replacing `type: module` with `type: js` in your resource reference, like below. 501 | 502 | ```yaml 503 | resources: 504 | - url: ... 505 | type: js 506 | ``` 507 | 508 | ## Inspiration 509 | - [@ciotlosm](https://github.com/ciotlosm) - [custom-lovelace](https://github.com/ciotlosm/custom-lovelace) 510 | - [@c727](https://github.com/c727) - [Custom UI: Mini media player](https://community.home-assistant.io/t/custom-ui-mini-media-player/40135) 511 | 512 | ## License 513 | This project is under the MIT license. 514 | -------------------------------------------------------------------------------- /info.md: -------------------------------------------------------------------------------- 1 | [![](https://img.shields.io/github/release/kalkih/mini-media-player.svg?style=flat-square)](https://github.com/kalkih/mini-media-player/releases/latest) 2 | 3 | A minimalistic yet customizable media player card for [Home Assistant](https://github.com/home-assistant/home-assistant) Lovelace UI. 4 | 5 | ![Preview Image](https://user-images.githubusercontent.com/457678/47517460-9282d600-d888-11e8-9705-cf9ec3698c3c.png) 6 | 7 | ## Features 8 | * Minimalistic & compact design 9 | * Custom shortcuts (scripts, services, play media e.g.) 10 | * Multiroom management (Sonos, Soundtouch, Squeezebox, Bluesound, Musiccast, Linkplay) 11 | * Highly customizable, hide/display specific items 12 | * Playback & volume controls 13 | * Seek through playback 14 | * Source select 15 | * Sound mode select 16 | * Progress & time indicators 17 | * Text-to-speech input 18 | * Shuffle support 19 | * Custom idle view options 20 | * Custom background 21 | * Theme variables 22 | * Responsive design 23 | * Multiple artwork styles 24 | * more... 25 | 26 | 27 | ## Examples 28 | 29 | #### Basic card 30 | Basic card example 31 | 32 | #### Compact card 33 | Compact card example 34 | 35 | #### Card with media shortcuts 36 | Card with media shortcuts example 37 | 38 | #### Grouped cards 39 | Grouped cards example 40 | 41 | #### Stacked cards 42 | Stacked cards example 43 | 44 | #### Speaker group management 45 | sonos group management example 46 | 47 | **Check the repository for card options & example configurations** -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mini-media-player", 3 | "version": "1.16.9", 4 | "description": "A minimalistic yet customizable media player card for Home Assistant Lovelace UI", 5 | "keywords": [ 6 | "home-assistant", 7 | "homeassistant", 8 | "hass", 9 | "automation", 10 | "lovelace", 11 | "media", 12 | "custom-cards" 13 | ], 14 | "main": "src/main.js", 15 | "module": "src/main.js", 16 | "repository": "git@github.com:kalkih/mini-media-player.git", 17 | "author": "Karl Kihlström ", 18 | "license": "MIT", 19 | "dependencies": { 20 | "home-assistant-js-websocket": "^6.1.0", 21 | "lit-element": "^2.3.1", 22 | "lit-html": "^1.2.1", 23 | "node-vibrant": "^3.2.1-alpha.1", 24 | "resize-observer-polyfill": "^1.5.1" 25 | }, 26 | "devDependencies": { 27 | "@babel/core": "^7.15.0", 28 | "@babel/plugin-proposal-class-properties": "^7.14.5", 29 | "@babel/plugin-proposal-decorators": "^7.14.5", 30 | "@rollup/plugin-json": "^4.1.0", 31 | "@typescript-eslint/eslint-plugin": "^4.33.0", 32 | "@typescript-eslint/parser": "^4.33.0", 33 | "eslint": "^7.32.0", 34 | "eslint-config-airbnb-base": "^14.2.1", 35 | "eslint-config-prettier": "^8.3.0", 36 | "eslint-plugin-import": "^2.24.0", 37 | "eslint-plugin-prettier": "^4.0.0", 38 | "prettier": "^2.4.1", 39 | "rollup": "^2.58.0", 40 | "rollup-plugin-babel": "^4.4.0", 41 | "rollup-plugin-commonjs": "^10.1.0", 42 | "rollup-plugin-node-resolve": "^5.2.0", 43 | "rollup-plugin-serve": "^1.1.0", 44 | "rollup-plugin-terser": "^7.0.2", 45 | "rollup-plugin-typescript2": "^0.31.2", 46 | "typescript": "^4.4.3" 47 | }, 48 | "scripts": { 49 | "start": "rollup -c rollup.config.dev.js --watch", 50 | "build": "npm run lint && npm run rollup", 51 | "lint": "eslint src/*.ts", 52 | "rollup": "rollup -c" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /rollup.config.dev.js: -------------------------------------------------------------------------------- 1 | import resolve from 'rollup-plugin-node-resolve'; 2 | import typescript from 'rollup-plugin-typescript2'; 3 | import commonjs from 'rollup-plugin-commonjs'; 4 | import serve from 'rollup-plugin-serve'; 5 | import json from '@rollup/plugin-json'; 6 | 7 | export default { 8 | input: ['src/main.ts'], 9 | output: { 10 | file: './dist/mini-media-player-bundle.js', 11 | format: 'es', 12 | inlineDynamicImports: true, 13 | }, 14 | plugins: [ 15 | resolve(), 16 | commonjs(), 17 | typescript(), 18 | json(), 19 | serve({ 20 | contentBase: './dist', 21 | host: '0.0.0.0', 22 | port: 5059, 23 | allowCrossOrigin: true, 24 | headers: { 25 | 'Access-Control-Allow-Origin': '*', 26 | }, 27 | }), 28 | ], 29 | }; 30 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from 'rollup-plugin-typescript2'; 2 | import commonjs from 'rollup-plugin-commonjs'; 3 | import resolve from 'rollup-plugin-node-resolve'; 4 | import babel from 'rollup-plugin-babel'; 5 | import { terser } from 'rollup-plugin-terser'; 6 | import serve from 'rollup-plugin-serve'; 7 | import json from '@rollup/plugin-json'; 8 | 9 | const dev = process.env.ROLLUP_WATCH; 10 | 11 | const serveopts = { 12 | contentBase: ['./dist'], 13 | host: '0.0.0.0', 14 | port: 5000, 15 | allowCrossOrigin: true, 16 | headers: { 17 | 'Access-Control-Allow-Origin': '*', 18 | }, 19 | }; 20 | 21 | const plugins = [ 22 | resolve(), 23 | commonjs(), 24 | typescript(), 25 | json(), 26 | babel({ 27 | exclude: 'node_modules/**', 28 | }), 29 | dev && serve(serveopts), 30 | !dev && terser(), 31 | ]; 32 | 33 | export default [ 34 | { 35 | input: 'src/main.ts', 36 | output: { 37 | file: './dist/mini-media-player-bundle.js', 38 | format: 'es', 39 | inlineDynamicImports: true, 40 | }, 41 | plugins: [...plugins], 42 | }, 43 | ]; 44 | -------------------------------------------------------------------------------- /src/components/button.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css, customElement, CSSResult } from 'lit-element'; 2 | 3 | @customElement('mmp-button') 4 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 5 | class MiniMediaPlayerButton extends LitElement { 6 | render() { 7 | return html` 8 |
9 |
10 | 11 |
12 | 13 |
14 | `; 15 | } 16 | 17 | static get styles(): CSSResult { 18 | return css` 19 | :host { 20 | position: relative; 21 | box-sizing: border-box; 22 | margin: 4px; 23 | min-width: 0; 24 | overflow: hidden; 25 | transition: background 0.5s; 26 | border-radius: 4px; 27 | font-weight: 500; 28 | } 29 | :host([raised]) { 30 | background: var(--mmp-button-color); 31 | min-height: calc(var(--mmp-unit) * 0.8); 32 | box-shadow: 0px 3px 1px -2px rgba(0, 0, 0, 0.2), 0px 2px 2px 0px rgba(0, 0, 0, 0.14), 33 | 0px 1px 5px 0px rgba(0, 0, 0, 0.12); 34 | } 35 | :host([color]) { 36 | background: var(--mmp-active-color); 37 | transition: background 0.25s; 38 | opacity: 1; 39 | } 40 | :host([faded]) { 41 | opacity: 0.75; 42 | } 43 | :host([disabled]) { 44 | opacity: 0.25; 45 | pointer-events: none; 46 | } 47 | .container { 48 | height: 100%; 49 | width: 100%; 50 | } 51 | .slot-container { 52 | height: 100%; 53 | display: flex; 54 | align-items: center; 55 | justify-content: center; 56 | margin: 0 8px; 57 | width: auto; 58 | } 59 | paper-ripple { 60 | position: absolute; 61 | left: 0; 62 | right: 0; 63 | top: 0; 64 | bottom: 0; 65 | } 66 | `; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/components/checkbox.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css, customElement, CSSResult, property } from 'lit-element'; 2 | 3 | @customElement('mmp-checkbox') 4 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 5 | class MiniMediaPlayerCheckbox extends LitElement { 6 | @property({ attribute: false }) public checked!: boolean; 7 | @property({ attribute: false }) public disabled!: boolean; 8 | @property({ attribute: false }) public label!: string; 9 | 10 | render() { 11 | return html` 12 | 13 | 14 | ${this.label} 15 | 16 | `; 17 | } 18 | 19 | static get styles(): CSSResult { 20 | return css` 21 | :host { 22 | display: flex; 23 | padding: 0.6em 0; 24 | align-items: center; 25 | } 26 | span { 27 | margin-left: 1em; 28 | font-weight: 400; 29 | } 30 | span[disabled] { 31 | opacity: 0.65; 32 | } 33 | `; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/components/dropdown.js: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css } from 'lit-element'; 2 | 3 | import { ICON } from '../const'; 4 | import sharedStyle from '../sharedStyle'; 5 | import './button'; 6 | 7 | class MiniMediaPlayerDropdown extends LitElement { 8 | static get properties() { 9 | return { 10 | items: [], 11 | label: String, 12 | selected: String, 13 | id: String, 14 | isOpen: Boolean, 15 | }; 16 | } 17 | 18 | get selectedIndex() { 19 | return this.items.map(item => item.id).indexOf(this.selected); 20 | } 21 | 22 | firstUpdated() { 23 | const menu = this.shadowRoot.querySelector('#menu'); 24 | const button = this.shadowRoot.querySelector('#button'); 25 | menu.anchor = button; 26 | } 27 | 28 | render() { 29 | return html` 30 |
e.stopPropagation()} 33 | ?open=${this.isOpen}> 34 | ${this.icon ? html` 35 | 40 | 41 | 42 | ` : html` 43 | 45 |
46 | 47 | ${this.selected || this.label} 48 | 49 | 50 |
51 |
52 | `} 53 | 60 | ${this.items.map(item => html` 61 | 62 | ${item.icon ? html`` : ''} 63 | ${item.name ? html`${item.name}` : ''} 64 | `)} 65 | 66 |
67 | `; 68 | } 69 | 70 | onChange(e) { 71 | const { index } = e.detail; 72 | if (index !== this.selectedIndex && this.items[index]) { 73 | this.dispatchEvent(new CustomEvent('change', { 74 | detail: this.items[index], 75 | })); 76 | } 77 | } 78 | 79 | handleClose(e) { 80 | e.stopPropagation(); 81 | this.isOpen = false; 82 | } 83 | 84 | toggleMenu() { 85 | const menu = this.shadowRoot.querySelector('#menu'); 86 | menu.open = !menu.open; 87 | this.isOpen = menu.open; 88 | } 89 | 90 | static get styles() { 91 | return [ 92 | sharedStyle, 93 | css` 94 | :host { 95 | display: block; 96 | } 97 | :host([faded]) { 98 | opacity: .75; 99 | } 100 | :host[small] .mmp-dropdown__label { 101 | max-width: 60px; 102 | display: block; 103 | position: relative; 104 | width: auto; 105 | text-transform: initial; 106 | } 107 | :host[full] .mmp-dropdown__label { 108 | max-width: none; 109 | } 110 | .mmp-dropdown { 111 | padding: 0; 112 | display: block; 113 | position: relative; 114 | } 115 | .mmp-dropdown__button { 116 | display: flex; 117 | font-size: 1em; 118 | justify-content: space-between; 119 | align-items: center; 120 | height: calc(var(--mmp-unit) - 4px); 121 | margin: 2px 0; 122 | } 123 | .mmp-dropdown__button.icon { 124 | height: var(--mmp-unit); 125 | margin: 0; 126 | } 127 | .mmp-dropdown__button > div { 128 | display: flex; 129 | flex: 1; 130 | justify-content: space-between; 131 | align-items: center; 132 | height: calc(var(--mmp-unit) - 4px); 133 | max-width: 100%; 134 | } 135 | .mmp-dropdown__label { 136 | text-align: left; 137 | text-transform: none; 138 | } 139 | .mmp-dropdown__icon { 140 | height: auto; 141 | width: calc(var(--mmp-unit) * .6); 142 | min-width: calc(var(--mmp-unit) * .6); 143 | } 144 | mwc-list-item > *:nth-child(2) { 145 | margin-left: 4px; 146 | } 147 | .mmp-dropdown[open] mmp-button ha-icon { 148 | color: var(--mmp-accent-color); 149 | transform: rotate(180deg); 150 | } 151 | .mmp-dropdown[open] mmp-icon-button { 152 | color: var(--mmp-accent-color); 153 | transform: rotate(180deg); 154 | } 155 | .mmp-dropdown[open] mmp-icon-button[focused] { 156 | color: var(--mmp-text-color); 157 | transform: rotate(0deg); 158 | } 159 | `, 160 | ]; 161 | } 162 | } 163 | 164 | customElements.define('mmp-dropdown', MiniMediaPlayerDropdown); 165 | -------------------------------------------------------------------------------- /src/components/groupItem.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css, property, CSSResult, customElement } from 'lit-element'; 2 | import { MiniMediaPlayerSpeakerGroupEntry } from '../config/types'; 3 | import { HomeAssistant } from '../types'; 4 | 5 | import t from '../utils/translation'; 6 | 7 | import './checkbox'; 8 | 9 | export interface GroupChangeEvent extends CustomEvent { 10 | detail: { 11 | entity: string; 12 | checked: boolean; 13 | }; 14 | } 15 | 16 | @customElement('mmp-group-item') 17 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 18 | class MiniMediaPlayerGroupItem extends LitElement { 19 | @property({ attribute: false }) public hass!: HomeAssistant; 20 | @property({ attribute: false }) public item!: MiniMediaPlayerSpeakerGroupEntry; 21 | @property({ attribute: false }) public checked!: boolean; 22 | @property({ attribute: false }) public disabled!: boolean; 23 | @property({ attribute: false }) public master!: boolean; 24 | 25 | render() { 26 | return html` 27 | 33 | ${this.item.name} ${this.master ? html`(${t(this.hass, 'label.master')})` : ''} 34 | 35 | `; 36 | } 37 | 38 | private handleClick(ev: MouseEvent): void { 39 | ev.stopPropagation(); 40 | ev.preventDefault(); 41 | if (this.disabled) return; 42 | this.dispatchEvent( 43 | new CustomEvent('change', { 44 | detail: { 45 | entity: this.item.entity_id, 46 | checked: !this.checked, 47 | }, 48 | }), 49 | ); 50 | } 51 | 52 | static get styles(): CSSResult { 53 | return css` 54 | .master { 55 | font-weight: 500; 56 | } 57 | `; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/components/groupList.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css, customElement, property, CSSResult, TemplateResult } from 'lit-element'; 2 | 3 | import t from '../utils/translation'; 4 | 5 | import './groupItem'; 6 | import './button'; 7 | import { HomeAssistant } from '../types'; 8 | import MediaPlayerObject from '../model'; 9 | import { MiniMediaPlayerSpeakerGroupEntry } from '../config/types'; 10 | import { GroupChangeEvent } from './groupItem'; 11 | 12 | @customElement('mmp-group-list') 13 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 14 | class MiniMediaPlayerGroupList extends LitElement { 15 | @property({ attribute: false }) public hass!: HomeAssistant; 16 | @property({ attribute: false }) public entities!: MiniMediaPlayerSpeakerGroupEntry[]; 17 | @property({ attribute: false }) public player!: MediaPlayerObject; 18 | @property({ attribute: false }) public visible!: boolean; 19 | 20 | get group(): string[] { 21 | return this.player.group; 22 | } 23 | 24 | get master(): string { 25 | return this.player.master; 26 | } 27 | 28 | get isMaster(): boolean { 29 | return this.player.isMaster; 30 | } 31 | 32 | get isGrouped(): boolean { 33 | return this.player.isGrouped; 34 | } 35 | 36 | private handleGroupChange(ev: GroupChangeEvent): void { 37 | const { entity, checked } = ev.detail; 38 | this.player.handleGroupChange(ev, entity, checked); 39 | } 40 | 41 | render() { 42 | if (!this.visible) return html``; 43 | const { group, isMaster, isGrouped } = this; 44 | const { id } = this.player; 45 | return html` 46 |
47 | ${t(this.hass, 'title.speaker_management')} 48 | ${this.entities.map((item) => this.renderItem(item, id))} 49 |
50 | this.player.handleGroupChange(e, id, false)}> 51 | ${t(this.hass, 'label.leave')} 52 | 53 | ${isGrouped && isMaster 54 | ? html` 55 | this.player.handleGroupChange(e, group, false)}> 56 | ${t(this.hass, 'label.ungroup')} 57 | 58 | ` 59 | : html``} 60 | 64 | this.player.handleGroupChange( 65 | e, 66 | this.entities.map((item) => item.entity_id), 67 | true, 68 | )} 69 | > 70 | ${t(this.hass, 'label.group_all')} 71 | 72 |
73 |
74 | `; 75 | } 76 | 77 | private renderItem(item: MiniMediaPlayerSpeakerGroupEntry, entityId: string): TemplateResult { 78 | const itemId = item.entity_id; 79 | return html` `; 87 | } 88 | 89 | static get styles(): CSSResult { 90 | return css` 91 | .mmp-group-list { 92 | display: flex; 93 | flex-direction: column; 94 | margin-left: 8px; 95 | margin-bottom: 8px; 96 | } 97 | .mmp-group-list__title { 98 | font-weight: 500; 99 | letter-spacing: 0.1em; 100 | margin: 8px 0 4px; 101 | text-transform: uppercase; 102 | } 103 | .mmp-group-list__buttons { 104 | display: flex; 105 | } 106 | mmp-button { 107 | margin: 8px 8px 0 0; 108 | min-width: 0; 109 | text-transform: uppercase; 110 | text-align: center; 111 | width: 50%; 112 | --mdc-theme-primary: transparent; 113 | } 114 | `; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/components/mediaControls.js: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css } from 'lit-element'; 2 | import { classMap } from 'lit-html/directives/class-map'; 3 | 4 | import { ICON, REPEAT_STATE } from '../const'; 5 | import sharedStyle from '../sharedStyle'; 6 | 7 | class MiniMediaPlayerMediaControls extends LitElement { 8 | static get properties() { 9 | return { 10 | player: {}, 11 | config: {}, 12 | break: Boolean, 13 | }; 14 | } 15 | 16 | get showShuffle() { 17 | return !this.config.hide.shuffle && this.player.supportsShuffle; 18 | } 19 | 20 | get showRepeat() { 21 | return !this.config.hide.repeat && this.player.supportsRepeat; 22 | } 23 | 24 | get maxVol() { 25 | return this.config.max_volume || 100; 26 | } 27 | 28 | get minVol() { 29 | return this.config.min_volume || 0; 30 | } 31 | 32 | get vol() { 33 | return Math.round(this.player.vol * 100); 34 | } 35 | 36 | get jumpAmount() { 37 | return this.config.jump_amount || 10; 38 | } 39 | 40 | render() { 41 | const { hide } = this.config; 42 | return html` 43 | ${!hide.volume ? this.renderVolControls(this.player.muted) : html``} 44 | ${this.renderShuffleButton()} 45 | ${this.renderRepeatButton()} 46 | ${!hide.controls ? html` 47 |
48 | ${!hide.prev && this.player.supportsPrev ? html` 49 | this.player.prev(e)} 51 | .icon=${ICON.PREV}> 52 | 53 | ` : ''} 54 | ${this.renderJumpBackwardButton()} 55 | ${this.renderPlayButtons()} 56 | ${this.renderJumpForwardButton()} 57 | ${!hide.next && this.player.supportsNext ? html` 58 | this.player.next(e)} 60 | .icon=${ICON.NEXT}> 61 | 62 | ` : ''} 63 |
64 | ` : html``} 65 | `; 66 | } 67 | 68 | renderShuffleButton() { 69 | return this.showShuffle ? html` 70 |
71 | this.player.toggleShuffle(e)} 74 | .icon=${ICON.SHUFFLE} 75 | ?color=${this.player.shuffle}> 76 | 77 | 78 |
79 | ` : html``; 80 | } 81 | 82 | renderRepeatButton() { 83 | if (!this.showRepeat) return html``; 84 | 85 | const colored = [REPEAT_STATE.ONE, REPEAT_STATE.ALL].includes(this.player.repeat); 86 | return html` 87 |
88 | this.player.toggleRepeat(e)} 91 | .icon=${ICON.REPEAT[this.player.repeat]} 92 | ?color=${colored}> 93 | 94 | 95 |
96 | `; 97 | } 98 | 99 | renderVolControls(muted) { 100 | const volumeControls = this.config.volume_stateless 101 | ? this.renderVolButtons(muted) 102 | : this.renderVolSlider(muted); 103 | 104 | const classes = classMap({ 105 | '--buttons': this.config.volume_stateless, 106 | 'mmp-media-controls__volume': true, 107 | flex: true, 108 | }); 109 | 110 | const showVolumeLevel = !this.config.hide.volume_level; 111 | return html` 112 |
113 | ${volumeControls} 114 | ${showVolumeLevel ? this.renderVolLevel() : ''} 115 |
`; 116 | } 117 | 118 | renderVolSlider(muted) { 119 | return html` 120 | ${this.renderMuteButton(muted)} 121 | e.stopPropagation()} 124 | ?disabled=${muted} 125 | min=${this.minVol} max=${this.maxVol} 126 | value=${this.player.vol * 100} 127 | step=${this.config.volume_step || 1} 128 | dir=${'ltr'} 129 | ignore-bar-touch pin labeled> 130 | 131 | `; 132 | } 133 | 134 | renderVolButtons(muted) { 135 | return html` 136 | ${this.renderMuteButton(muted)} 137 | this.player.volumeDown(e)} 139 | .icon=${ICON.VOL_DOWN}> 140 | 141 | 142 | this.player.volumeUp(e)} 144 | .icon=${ICON.VOL_UP}> 145 | 146 | 147 | `; 148 | } 149 | 150 | renderVolLevel() { 151 | return html` 152 | ${this.vol}% 153 | `; 154 | } 155 | 156 | renderMuteButton(muted) { 157 | if (this.config.hide.mute) return; 158 | switch (this.config.replace_mute) { 159 | case 'play': 160 | case 'play_pause': 161 | return html` 162 | this.player.playPause(e)} 164 | .icon=${ICON.PLAY[this.player.isPlaying]}> 165 | 166 | 167 | `; 168 | case 'stop': 169 | return html` 170 | this.player.stop(e)} 172 | .icon=${ICON.STOP.true}> 173 | 174 | 175 | `; 176 | case 'play_stop': 177 | return html` 178 | this.player.playStop(e)} 180 | .icon=${ICON.STOP[this.player.isPlaying]}> 181 | 182 | 183 | `; 184 | case 'next': 185 | return html` 186 | this.player.next(e)} 188 | .icon=${ICON.NEXT}> 189 | 190 | 191 | `; 192 | default: 193 | if (!this.player.supportsMute) return; 194 | return html` 195 | this.player.toggleMute(e)} 197 | .icon=${ICON.MUTE[muted]}> 198 | 199 | 200 | `; 201 | } 202 | } 203 | 204 | renderPlayButtons() { 205 | const { hide } = this.config; 206 | return html` 207 | ${!hide.play_pause ? this.player.assumedState ? html` 208 | this.player.play(e)} 210 | .icon=${ICON.PLAY.false}> 211 | 212 | 213 | this.player.pause(e)} 215 | .icon=${ICON.PLAY.true}> 216 | 217 | 218 | ` : html` 219 | this.player.playPause(e)} 221 | .icon=${ICON.PLAY[this.player.isPlaying]}> 222 | 223 | 224 | ` : html``} 225 | ${!hide.play_stop ? html` 226 | this.handleStop(e)} 228 | .icon=${hide.play_pause ? ICON.STOP[this.player.isPlaying] : ICON.STOP.true}> 229 | 230 | 231 | ` : html``} 232 | `; 233 | } 234 | 235 | renderJumpForwardButton() { 236 | const hidden = this.config.hide.jump; 237 | if (hidden || !this.player.hasProgress) return html``; 238 | return html` 239 | this.player.jump(e, this.jumpAmount)} 241 | .icon=${ICON.FAST_FORWARD}> 242 | 243 | 244 | `; 245 | } 246 | 247 | renderJumpBackwardButton() { 248 | const hidden = this.config.hide.jump; 249 | if (hidden || !this.player.hasProgress) return html``; 250 | return html` 251 | this.player.jump(e, -this.jumpAmount)} 253 | .icon=${ICON.REWIND}> 254 | 255 | 256 | `; 257 | } 258 | 259 | handleStop(e) { 260 | return this.config.hide.play_pause ? this.player.playStop(e) : this.player.stop(e); 261 | } 262 | 263 | handleVolumeChange(ev) { 264 | const vol = parseFloat(ev.target.value) / 100; 265 | this.player.setVolume(ev, vol); 266 | } 267 | 268 | static get styles() { 269 | return [ 270 | sharedStyle, 271 | css` 272 | :host { 273 | display: flex; 274 | width: 100%; 275 | justify-content: space-between; 276 | } 277 | .flex { 278 | display: flex; 279 | flex: 1; 280 | justify-content: space-between; 281 | } 282 | ha-slider { 283 | max-width: none; 284 | min-width: 100px; 285 | width: 100%; 286 | --md-sys-color-primary: var(--mmp-accent-color); 287 | } 288 | ha-icon-button { 289 | min-width: var(--mmp-unit); 290 | } 291 | .mmp-media-controls__volume { 292 | flex: 100; 293 | max-height: var(--mmp-unit); 294 | align-items: center; 295 | } 296 | .mmp-media-controls__volume.--buttons { 297 | justify-content: left; 298 | } 299 | .mmp-media-controls__media { 300 | margin-right: 0; 301 | margin-left: auto; 302 | justify-content: inherit; 303 | } 304 | .mmp-media-controls__media[flow] { 305 | max-width: none; 306 | justify-content: space-between; 307 | } 308 | .mmp-media-controls__shuffle, 309 | .mmp-media-controls__repeat { 310 | flex: 3; 311 | flex-shrink: 200; 312 | justify-content: center; 313 | } 314 | `, 315 | ]; 316 | } 317 | } 318 | 319 | customElements.define('mmp-media-controls', MiniMediaPlayerMediaControls); 320 | -------------------------------------------------------------------------------- /src/components/powerstrip.js: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css } from 'lit-element'; 2 | 3 | import './sourceMenu'; 4 | import './soundMenu'; 5 | import './mediaControls'; 6 | 7 | import { ICON } from '../const'; 8 | import sharedStyle from '../sharedStyle'; 9 | 10 | import t from '../utils/translation'; 11 | 12 | class MiniMediaPlayerPowerstrip extends LitElement { 13 | static get properties() { 14 | return { 15 | hass: {}, 16 | player: {}, 17 | config: {}, 18 | groupVisible: Boolean, 19 | idle: Boolean, 20 | }; 21 | } 22 | 23 | get icon() { 24 | return this.config.speaker_group.icon || ICON.GROUP; 25 | } 26 | 27 | get showGroupButton() { 28 | return this.config.speaker_group.entities.length > 0 && !this.config.hide.group_button; 29 | } 30 | 31 | get showPowerButton() { 32 | return !this.config.hide.power; 33 | } 34 | 35 | get powerColor() { 36 | return this.player.isActive && !this.config.hide.power_state; 37 | } 38 | 39 | get sourceSize() { 40 | return this.config.source === 'icon' || this.hasControls || this.idle; 41 | } 42 | 43 | get soundSize() { 44 | return this.config.sound_mode === 'icon' || this.hasControls || this.idle; 45 | } 46 | 47 | get hasControls() { 48 | return this.player.isActive && this.config.hide.controls !== this.config.hide.volume; 49 | } 50 | 51 | get hasSource() { 52 | return this.player.sources.length > 0 && !this.config.hide.source; 53 | } 54 | 55 | get hasSoundMode() { 56 | return this.player.soundModes.length > 0 && !this.config.hide.sound_mode; 57 | } 58 | 59 | get showLabel() { 60 | return !this.config.hide.state_label; 61 | } 62 | 63 | render() { 64 | if (this.player.isUnavailable && this.showLabel) 65 | return html` 66 | ${t(this.hass, 'state.unavailable', 'state.default.unavailable')} 67 | `; 68 | 69 | return html` 70 | ${this.idle ? this.renderIdleView : ''} 71 | ${this.hasControls 72 | ? html` ` 73 | : ''} 74 | ${this.hasSource 75 | ? html` 76 | ` 77 | : ''} 78 | ${this.hasSoundMode 79 | ? html` 84 | ` 85 | : ''} 86 | ${this.showGroupButton 87 | ? html` 94 | 95 | ` 96 | : ''} 97 | ${this.showPowerButton 98 | ? html` this.player.toggle(e)} 102 | ?color=${this.powerColor} 103 | > 104 | 105 | ` 106 | : ''} 107 | `; 108 | } 109 | 110 | handleGroupClick(ev) { 111 | ev.stopPropagation(); 112 | this.dispatchEvent(new CustomEvent('toggleGroupList')); 113 | } 114 | 115 | get renderIdleView() { 116 | if (this.player.isPaused) 117 | return html` this.player.playPause(e)}> 118 | 119 | `; 120 | else if (this.showLabel) 121 | return html` ${t(this.hass, 'state.idle', 'state.media_player.idle')} `; 122 | else return html``; 123 | } 124 | 125 | static get styles() { 126 | return [ 127 | sharedStyle, 128 | css` 129 | :host { 130 | display: flex; 131 | line-height: var(--mmp-unit); 132 | max-height: var(--mmp-unit); 133 | } 134 | :host([flow]) mmp-media-controls { 135 | max-width: unset; 136 | } 137 | mmp-media-controls { 138 | max-width: calc(var(--mmp-unit) * 5); 139 | line-height: initial; 140 | justify-content: flex-end; 141 | } 142 | .group-button { 143 | --mdc-icon-size: calc(var(--mmp-unit) * 0.5); 144 | } 145 | ha-icon-button { 146 | min-width: var(--mmp-unit); 147 | } 148 | `, 149 | ]; 150 | } 151 | } 152 | 153 | customElements.define('mmp-powerstrip', MiniMediaPlayerPowerstrip); 154 | -------------------------------------------------------------------------------- /src/components/progress.js: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css } from 'lit-element'; 2 | import { classMap } from 'lit-html/directives/class-map'; 3 | 4 | import convertProgress from '../utils/getProgress'; 5 | import {styleMap} from 'lit-html/directives/style-map'; 6 | 7 | class MiniMediaPlayerProgress extends LitElement { 8 | static get properties() { 9 | return { 10 | _player: {}, 11 | showTime: Boolean, 12 | showRemainingTime: Boolean, 13 | progress: Number, 14 | duration: Number, 15 | tracker: {}, 16 | seekProgress: Number, 17 | seekWidth: Number, 18 | track: Boolean, 19 | }; 20 | } 21 | 22 | set player(player) { 23 | this._player = player; 24 | if (this.hasProgress) { 25 | this.trackProgress(); 26 | } 27 | } 28 | 29 | get duration() { 30 | return this.player.mediaDuration; 31 | } 32 | 33 | get player() { 34 | return this._player; 35 | } 36 | 37 | get hasProgress() { 38 | return this.player.hasProgress; 39 | } 40 | 41 | get width() { 42 | return this.shadowRoot.querySelector('.mmp-progress').offsetWidth; 43 | } 44 | 45 | get offset() { 46 | return this.getBoundingClientRect().left; 47 | } 48 | 49 | get classes() { 50 | return classMap({ 51 | transiting: !this.seekProgress, 52 | seeking: this.seekProgress, 53 | }); 54 | } 55 | 56 | render() { 57 | return html` 58 |
e.stopPropagation()} 65 | ?paused=${!this.player.isPlaying}> 66 | ${this.showTime ? html` 67 |
68 | ${convertProgress(this.seekProgress || this.progress)} 69 |
70 | ${this.showTime ? html` 71 | 72 | -${(convertProgress(this.duration - (this.seekProgress || this.progress)))} | 73 | 74 | ` : ''} 75 | ${convertProgress(this.duration)} 76 |
77 |
78 | ` : ''} 79 |
80 |
81 | `; 82 | } 83 | progressBarStyle() { 84 | return styleMap({ 85 | width: `${((this.seekProgress || this.progress) / this.duration) * 100}%` 86 | }); 87 | } 88 | 89 | trackProgress() { 90 | this.progress = this.player.progress; 91 | if (!this.tracker) 92 | this.tracker = setInterval(() => this.trackProgress(), 1000); 93 | if (!this.player.isPlaying) { 94 | clearInterval(this.tracker); 95 | this.tracker = null; 96 | } 97 | } 98 | 99 | initSeek(e) { 100 | const x = e.offsetX || (e.touches[0].pageX - this.offset); 101 | this.seekWidth = this.width; 102 | this.seekProgress = this.calcProgress(x); 103 | this.addEventListener('touchmove', this.moveSeek); 104 | this.addEventListener('mousemove', this.moveSeek); 105 | } 106 | 107 | resetSeek() { 108 | this.seekProgress = null; 109 | this.removeEventListener('touchmove', this.moveSeek); 110 | this.removeEventListener('mousemove', this.moveSeek); 111 | } 112 | 113 | moveSeek(e) { 114 | e.preventDefault(); 115 | const x = e.offsetX || (e.touches[0].pageX - this.offset); 116 | this.seekProgress = this.calcProgress(x); 117 | } 118 | 119 | handleSeek(e) { 120 | this.resetSeek(); 121 | const x = e.offsetX || (e.changedTouches[0].pageX - this.offset); 122 | const pos = this.calcProgress(x); 123 | this.player.seek(e, pos); 124 | } 125 | 126 | disconnectedCallback() { 127 | super.disconnectedCallback(); 128 | this.resetSeek(); 129 | clearInterval(this.tracker); 130 | this.tracker = null; 131 | } 132 | 133 | connectedCallback() { 134 | super.connectedCallback(); 135 | if (this.hasProgress) { 136 | this.trackProgress(); 137 | } 138 | } 139 | 140 | calcProgress(x) { 141 | const pos = (x / this.seekWidth) * this.duration; 142 | return Math.min(Math.max(pos, 0.1), this.duration); 143 | } 144 | 145 | static get styles() { 146 | return css` 147 | .mmp-progress { 148 | cursor: pointer; 149 | left: 0; right: 0; bottom: 0; 150 | position: absolute; 151 | pointer-events: auto; 152 | min-height: calc(var(--mmp-progress-height) + 10px); 153 | } 154 | .mmp-progress:before { 155 | content: ''; 156 | position: absolute; 157 | left: 0; 158 | right: 0; 159 | bottom: 0; 160 | height: var(--mmp-progress-height); 161 | background-color: rgba(100,100,100,.15); 162 | } 163 | .mmp-progress__duration { 164 | left: calc(var(--ha-card-border-radius, 4px) / 2); 165 | right: calc(var(--ha-card-border-radius, 4px) / 2); 166 | bottom: calc(var(--mmp-progress-height) + 6px); 167 | position: absolute; 168 | display: flex; 169 | justify-content: space-between; 170 | font-size: .8em; 171 | padding: 0 6px; 172 | z-index: 2 173 | } 174 | .mmp-progress__duration__remaining { 175 | opacity: .5; 176 | } 177 | .progress-bar { 178 | height: var(--mmp-progress-height); 179 | bottom: 0; 180 | position: absolute; 181 | width: 0; 182 | transition: height 0; 183 | z-index: 1; 184 | background-color: var(--mmp-accent-color); 185 | } 186 | .progress-bar.seeking { 187 | transition: height .15s ease-out; 188 | height: calc(var(--mmp-progress-height) + 4px); 189 | } 190 | .mmp-progress[paused] .progress-bar { 191 | background-color: var(--disabled-text-color, rgba(150,150,150,.5)); 192 | } 193 | `; 194 | } 195 | } 196 | 197 | customElements.define('mmp-progress', MiniMediaPlayerProgress); 198 | -------------------------------------------------------------------------------- /src/components/shortcuts.js: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css } from 'lit-element'; 2 | import { styleMap } from 'lit-html/directives/style-map'; 3 | 4 | import './dropdown'; 5 | import './button'; 6 | 7 | import sharedStyle from '../sharedStyle'; 8 | 9 | class MiniMediaPlayerShortcuts extends LitElement { 10 | static get properties() { 11 | return { 12 | player: {}, 13 | shortcuts: {}, 14 | }; 15 | } 16 | 17 | get buttons() { 18 | return this.shortcuts.buttons; 19 | } 20 | 21 | get list() { 22 | return this.shortcuts.list; 23 | } 24 | 25 | get show() { 26 | return (!this.shortcuts.hide_when_off || this.player.isActive); 27 | } 28 | 29 | get active() { 30 | return this.player.getAttribute(this.shortcuts.attribute); 31 | } 32 | 33 | get height() { 34 | return this.shortcuts.column_height || 36; 35 | } 36 | 37 | render() { 38 | if (!this.show) return html``; 39 | const { active } = this; 40 | 41 | const list = this.list ? html` 42 | 47 | 48 | ` : ''; 49 | 50 | const buttons = this.buttons ? html` 51 |
52 | ${this.buttons.map(item => html` 53 | this.handleShortcut(e, item)}> 60 |
61 | ${item.icon ? html`` : ''} 62 | ${item.image ? html`` : ''} 63 | ${item.name ? html`${item.name}` : ''} 64 |
65 |
`)} 66 |
67 | ` : ''; 68 | 69 | return html` 70 | ${buttons} 71 | ${list} 72 | `; 73 | } 74 | 75 | handleShortcut(ev, item) { 76 | const { type, id, data } = item || ev.detail; 77 | if (type === 'source') 78 | return this.player.setSource(ev, id); 79 | if (type === 'service') 80 | return this.player.toggleService(ev, id, data); 81 | if (type === 'script') 82 | return this.player.toggleScript(ev, id, data); 83 | if (type === 'sound_mode') 84 | return this.player.setSoundMode(ev, id); 85 | const options = { 86 | media_content_type: type, 87 | media_content_id: id, 88 | }; 89 | this.player.setMedia(ev, options); 90 | } 91 | 92 | shortcutStyle(item) { 93 | return { 94 | 'min-height': `${this.height}px`, 95 | ...(item.cover && { 'background-image': `url(${item.cover})` }), 96 | }; 97 | } 98 | 99 | static get styles() { 100 | return [ 101 | sharedStyle, 102 | css` 103 | .mmp-shortcuts__buttons { 104 | box-sizing: border-box; 105 | display: flex; 106 | flex-wrap: wrap; 107 | margin-top: 8px; 108 | } 109 | .mmp-shortcuts__button { 110 | min-width: calc(50% - 8px); 111 | flex: 1; 112 | background-size: cover; 113 | background-repeat: no-repeat; 114 | background-position: center center; 115 | } 116 | .mmp-shortcuts__button > div { 117 | display: flex; 118 | justify-content: center; 119 | align-items: center; 120 | width: 100%; 121 | padding: .2em 0; 122 | } 123 | .mmp-shortcuts__button > div[align='left'] { 124 | justify-content: flex-start; 125 | } 126 | .mmp-shortcuts__button > div[align='right'] { 127 | justify-content: flex-end; 128 | } 129 | .mmp-shortcuts__button[columns='1'] { 130 | min-width: calc(100% - 8px); 131 | } 132 | .mmp-shortcuts__button[columns='3'] { 133 | min-width: calc(33.33% - 8px); 134 | } 135 | .mmp-shortcuts__button[columns='4'] { 136 | min-width: calc(25% - 8px); 137 | } 138 | .mmp-shortcuts__button[columns='5'] { 139 | min-width: calc(20% - 8px); 140 | } 141 | .mmp-shortcuts__button[columns='6'] { 142 | min-width: calc(16.66% - 8px); 143 | } 144 | .mmp-shortcuts__button > div > span { 145 | line-height: calc(var(--mmp-unit) * .6); 146 | text-transform: initial; 147 | } 148 | .mmp-shortcuts__button > div > ha-icon { 149 | width: calc(var(--mmp-unit) * .6); 150 | height: calc(var(--mmp-unit) * .6); 151 | } 152 | .mmp-shortcuts__button > div > *:nth-child(2) { 153 | margin-left: 4px; 154 | } 155 | .mmp-shortcuts__button > div > img { 156 | height: 24px; 157 | } 158 | `, 159 | ]; 160 | } 161 | } 162 | 163 | customElements.define('mmp-shortcuts', MiniMediaPlayerShortcuts); 164 | -------------------------------------------------------------------------------- /src/components/soundMenu.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css, customElement, property, TemplateResult, CSSResult, state } from 'lit-element'; 2 | import MediaPlayerObject from '../model'; 3 | 4 | import './dropdown'; 5 | 6 | interface DropdownItem { 7 | name: string; 8 | id: string; 9 | type: 'soundMode'; 10 | } 11 | 12 | type ChangeEvent = CustomEvent; 13 | 14 | @customElement('mmp-sound-menu') 15 | export class MiniMediaPlayerSoundMenu extends LitElement { 16 | @property({ attribute: false }) public player!: MediaPlayerObject; 17 | 18 | @property({ attribute: false }) public icon!: boolean[]; 19 | 20 | @state() private selected?: string = undefined; 21 | 22 | get mode(): string { 23 | return this.player.soundMode; 24 | } 25 | 26 | get alternatives(): DropdownItem[] { 27 | return this.player.soundModes.map((mode) => ({ 28 | name: mode, 29 | id: mode, 30 | type: 'soundMode', 31 | })); 32 | } 33 | 34 | render(): TemplateResult { 35 | return html` 36 | 43 | `; 44 | } 45 | 46 | private handleChange(ev: ChangeEvent) { 47 | const { id } = ev.detail; 48 | this.player.setSoundMode(ev, id); 49 | this.selected = id; 50 | } 51 | 52 | static get styles(): CSSResult { 53 | return css` 54 | :host { 55 | max-width: 120px; 56 | min-width: var(--mmp-unit); 57 | } 58 | :host([full]) { 59 | max-width: none; 60 | } 61 | `; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/components/sourceMenu.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css, CSSResult, property, customElement, TemplateResult } from 'lit-element'; 2 | import MediaPlayerObject from '../model'; 3 | 4 | import './dropdown'; 5 | 6 | interface DropdownItem { 7 | name: string; 8 | id: string; 9 | type: 'source'; 10 | } 11 | 12 | type ChangeEvent = CustomEvent; 13 | 14 | @customElement('mmp-source-menu') 15 | export class MiniMediaPlayerSourceMenu extends LitElement { 16 | @property({ attribute: false }) public player!: MediaPlayerObject; 17 | 18 | @property({ attribute: false }) public icon!: boolean[]; 19 | 20 | get source(): string { 21 | return this.player.source; 22 | } 23 | 24 | get alternatives(): DropdownItem[] { 25 | return this.player.sources.map((source) => ({ 26 | name: source, 27 | id: source, 28 | type: 'source', 29 | })); 30 | } 31 | 32 | render(): TemplateResult { 33 | return html` 34 | 41 | `; 42 | } 43 | 44 | private handleSource(ev: ChangeEvent) { 45 | const { id } = ev.detail; 46 | this.player.setSource(ev, id); 47 | } 48 | 49 | static get styles(): CSSResult { 50 | return css` 51 | :host { 52 | max-width: 120px; 53 | min-width: var(--mmp-unit); 54 | } 55 | :host([full]) { 56 | max-width: none; 57 | } 58 | `; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/components/tts.js: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css } from 'lit-element'; 2 | 3 | import t from '../utils/translation'; 4 | 5 | class MiniMediaPlayerTts extends LitElement { 6 | static get properties() { 7 | return { 8 | hass: {}, 9 | config: {}, 10 | player: {}, 11 | }; 12 | } 13 | 14 | get label() { 15 | return t( 16 | this.hass, 17 | 'placeholder.tts', 18 | 'ui.card.media_player.text_to_speak', 19 | 'Say', 20 | ); 21 | } 22 | 23 | get input() { 24 | return this.shadowRoot.getElementById('tts-input'); 25 | } 26 | 27 | get message() { 28 | return this.input.value; 29 | } 30 | 31 | render() { 32 | return html` 33 | e.stopPropagation()} 38 | > 39 | 40 | 41 | ${t(this.hass, 'label.send')} 42 | 43 | `; 44 | } 45 | 46 | handleTts(e) { 47 | const { config, message } = this; 48 | const opts = { 49 | message, 50 | entity_id: config.entity_id || this.player.id, 51 | ...(config.entity_id === 'group' && { entity_id: this.player.group }), 52 | ...config.data, 53 | }; 54 | if (config.language) opts.language = config.language; 55 | switch (config.platform) { 56 | case 'alexa': 57 | this.hass.callService('notify', 'alexa_media', { 58 | message, 59 | data: { type: config.type || 'tts', ...config.data }, 60 | target: opts.entity_id, 61 | }); 62 | break; 63 | case 'sonos': 64 | this.hass.callService('script', 'sonos_say', { 65 | sonos_entity: opts.entity_id, 66 | volume: config.volume || 0.5, 67 | message, 68 | ...config.data, 69 | }); 70 | break; 71 | case 'webos': 72 | this.hass.callService('notify', opts.entity_id.split('.').slice(-1)[0], { 73 | message, 74 | ...config.data, 75 | }); 76 | break; 77 | case 'ga': 78 | this.hass.callService('notify', 'ga_broadcast', { 79 | message, 80 | ...config.data, 81 | }); 82 | break; 83 | case 'service': { 84 | const [domain, service] = (config.data.service || '').split('.'); 85 | const field = config.data.message_field || 'message'; 86 | const serviceData = { 87 | [field]: message, 88 | entity_id: opts.entity_id, 89 | ...(config.language ? { language: opts.language } : {}), 90 | ...(config.data.service_data || {}), 91 | }; 92 | this.hass.callService(domain, service, serviceData); 93 | break; 94 | } 95 | default: 96 | this.hass.callService('tts', `${config.platform}_say`, opts); 97 | } 98 | e.stopPropagation(); 99 | this.reset(); 100 | } 101 | 102 | reset() { 103 | this.input.value = ''; 104 | } 105 | 106 | static get styles() { 107 | return css` 108 | :host { 109 | align-items: center; 110 | margin: 8px 4px 0px; 111 | display: flex; 112 | } 113 | .mmp-tts__input { 114 | cursor: text; 115 | flex: 1; 116 | margin-right: 8px; 117 | } 118 | ha-card[rtl] .mmp-tts__input { 119 | margin-right: auto; 120 | margin-left: 8px; 121 | } 122 | .mmp-tts__button { 123 | margin: 0; 124 | height: 30px; 125 | padding: 0 .4em; 126 | } 127 | `; 128 | } 129 | } 130 | 131 | customElements.define('mmp-tts', MiniMediaPlayerTts); 132 | -------------------------------------------------------------------------------- /src/config/config.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_HIDE, LABEL_SHORTCUT } from '../const'; 2 | import { MiniMediaPlayerActionEvent, MiniMediaPlayerBaseConfiguration, MiniMediaPlayerConfiguration } from './types'; 3 | 4 | const validate = (config: MiniMediaPlayerBaseConfiguration): void => { 5 | if (typeof config.entity === 'undefined') { 6 | throw new Error('You need to specify the required entity option.'); 7 | } 8 | 9 | if (config.entity.split('.')[0] !== 'media_player') { 10 | throw new Error('Specify an entity from within the media_player domain.'); 11 | } 12 | 13 | if (typeof config.type === 'undefined') { 14 | throw new Error('You need to specify the required type option.'); 15 | } 16 | }; 17 | 18 | export const generateConfig = (config: MiniMediaPlayerBaseConfiguration): MiniMediaPlayerConfiguration => { 19 | validate(config); 20 | 21 | const conf: MiniMediaPlayerConfiguration = { 22 | artwork: 'default', 23 | info: 'default', 24 | group: false, 25 | volume_stateless: false, 26 | more_info: true, 27 | source: 'default', 28 | sound_mode: 'default', 29 | toggle_power: true, 30 | tap_action: { 31 | action: MiniMediaPlayerActionEvent.MORE_INFO, 32 | }, 33 | jump_amount: 10, 34 | ...config, 35 | hide: { ...DEFAULT_HIDE, ...config.hide }, 36 | speaker_group: { 37 | show_group_count: true, 38 | platform: 'sonos', 39 | supports_master: true, 40 | entities: [], 41 | ...config.sonos, 42 | ...config.speaker_group, 43 | }, 44 | shortcuts: { 45 | label: LABEL_SHORTCUT, 46 | ...config.shortcuts, 47 | }, 48 | max_volume: Number(config.max_volume) ?? 100, 49 | min_volume: Number(config.min_volume) || 0, 50 | }; 51 | 52 | conf.collapse = conf.hide.controls || conf.hide.volume; 53 | conf.info = conf.collapse && conf.info !== 'scroll' ? 'short' : conf.info; 54 | conf.flow = conf.hide.icon && conf.hide.name && conf.hide.info; 55 | 56 | return conf; 57 | }; 58 | 59 | export default generateConfig; 60 | -------------------------------------------------------------------------------- /src/config/types.ts: -------------------------------------------------------------------------------- 1 | export interface MiniMediaPlayerBaseConfiguration { 2 | type?: string; 3 | entity: string; 4 | name?: string; 5 | icon?: string; 6 | icon_image?: string; 7 | tap_action?: MiniMediaPlayerAction; 8 | group?: boolean; 9 | hide?: MiniMediaPlayerHideConfiguration; 10 | artwork?: 'default' | 'none' | 'cover' | 'full-cover' | 'material' | 'full-cover-fit'; 11 | tts?: MiniMediaPlayerTTSConfiguration; 12 | source?: 'default' | 'icon' | 'full'; 13 | sound_mode?: 'default' | 'icon' | 'full'; 14 | info?: 'default' | 'short' | 'scroll'; 15 | volume_stateless?: boolean; 16 | volume_step?: number; 17 | max_volume?: number; 18 | min_volume?: number; 19 | replace_mute?: 'play_pause' | 'stop' | 'play_stop' | 'next'; 20 | jump_amount?: number; 21 | toggle_power?: boolean; 22 | idle_view?: MiniMediaPlayerIdleViewConfiguration; 23 | background?: string; 24 | speaker_group?: MiniMediaPlayerSpeakerGroupBase; 25 | shortcuts?: MiniMediaPlayerShortcuts; 26 | scale?: number; 27 | 28 | /** 29 | * @internal Internal configuration options 30 | */ 31 | collapse: boolean; 32 | flow: boolean; 33 | 34 | /** 35 | * @deprecated The method should not be used 36 | */ 37 | sonos?: MiniMediaPlayerSpeakerGroupBase; 38 | } 39 | 40 | export interface MiniMediaPlayerConfiguration extends MiniMediaPlayerBaseConfiguration { 41 | entity: string; 42 | artwork: 'default' | 'none' | 'cover' | 'full-cover' | 'material' | 'full-cover-fit'; 43 | info: 'default' | 'short' | 'scroll'; 44 | group: boolean; 45 | volume_stateless: boolean; 46 | more_info: boolean; 47 | source: 'default' | 'icon' | 'full'; 48 | sound_mode: 'default' | 'icon' | 'full'; 49 | toggle_power: boolean; 50 | tap_action: MiniMediaPlayerAction; 51 | jump_amount: number; 52 | hide: MiniMediaPlayerHideConfiguration; 53 | speaker_group: MiniMediaPlayerSpeakerGroup; 54 | shortcuts: MiniMediaPlayerShortcuts; 55 | max_volume: number; 56 | min_volume: number; 57 | 58 | collapse: boolean; 59 | flow: boolean; 60 | } 61 | 62 | export interface MiniMediaPlayerShortcuts { 63 | list?: MiniMediaPlayerShortcutItem[]; 64 | Buttons?: MiniMediaPlayerShortcutItem[]; 65 | hide_when_off?: boolean; 66 | columns?: 1 | 2 | 3 | 4 | 5 | 6; 67 | column_height?: number; 68 | label?: string; 69 | attribute?: string; 70 | aling_text?: 'left' | 'right' | 'center'; 71 | } 72 | 73 | export interface MiniMediaPlayerShortcutItem { 74 | type: string; 75 | id: string; 76 | name?: string; 77 | icon?: string; 78 | image?: string; 79 | cover?: string; 80 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 81 | data?: Record; 82 | } 83 | 84 | export interface MiniMediaPlayerIdleViewConfiguration { 85 | when_idle?: boolean; 86 | when_paused?: boolean; 87 | when_standby?: boolean; 88 | after?: number; 89 | } 90 | 91 | export interface MiniMediaPlayerTTSConfiguration { 92 | platform: string; 93 | language?: string; 94 | entity_id?: string | 'all' | 'group' | string[]; 95 | volume?: number; 96 | type?: string | 'tts' | 'announce' | 'push'; 97 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 98 | data?: Record; 99 | } 100 | 101 | export interface MiniMediaPlayerAction { 102 | action: MiniMediaPlayerActionEvent; 103 | entity?: string; 104 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 105 | service?: Record; 106 | service_data?: string; 107 | navigation_path?: string; 108 | url?: string; 109 | new_tab?: boolean; 110 | haptic?: 'success' | 'warning' | 'failure' | 'light' | 'medium' | 'heavy' | 'selection'; 111 | } 112 | 113 | export enum MiniMediaPlayerActionEvent { 114 | MORE_INFO = 'more-info', 115 | NAVIGATE = 'navigate', 116 | CALL_SERVICE = 'call-service', 117 | URL = 'url', 118 | FIRE_DOM_EVENT = 'fire-dom-event', 119 | NONE = 'none', 120 | } 121 | 122 | export interface MiniMediaPlayerSpeakerGroupBase { 123 | entities: MiniMediaPlayerSpeakerGroupEntry[]; 124 | platform?: string; 125 | sync_volume?: boolean; 126 | expanded?: boolean; 127 | show_group_count?: boolean; 128 | icon?: string; 129 | group_mgmt_entity?: string; 130 | supports_master?: boolean; 131 | } 132 | 133 | export interface MiniMediaPlayerSpeakerGroup extends MiniMediaPlayerSpeakerGroupBase { 134 | entities: MiniMediaPlayerSpeakerGroupEntry[]; 135 | platform: string; 136 | show_group_count: boolean; 137 | supports_master: boolean; 138 | } 139 | 140 | export interface MiniMediaPlayerSpeakerGroupEntry { 141 | entity_id: string; 142 | name: string; 143 | volume_offset?: number; 144 | } 145 | 146 | interface MiniMediaPlayerHideConfiguration { 147 | repeat: boolean; 148 | shuffle: boolean; 149 | power_state: boolean; 150 | artwork_border: boolean; 151 | icon_state: boolean; 152 | sound_mode: boolean; 153 | group_button: boolean; 154 | runtime: boolean; 155 | runtime_remaining: boolean; 156 | volume: boolean; 157 | volume_level: boolean; 158 | controls: boolean; 159 | play_pause: boolean; 160 | play_stop: boolean; 161 | prev: boolean; 162 | next: boolean; 163 | jump: boolean; 164 | state_label: boolean; 165 | progress: boolean; 166 | icon: boolean; 167 | name: boolean; 168 | info: boolean; 169 | } 170 | -------------------------------------------------------------------------------- /src/const.ts: -------------------------------------------------------------------------------- 1 | const DEFAULT_HIDE = { 2 | repeat: true, 3 | shuffle: true, 4 | power_state: true, 5 | artwork_border: true, 6 | icon_state: true, 7 | sound_mode: true, 8 | group_button: false, 9 | runtime: true, 10 | runtime_remaining: true, 11 | volume: false, 12 | volume_level: true, 13 | controls: false, 14 | play_pause: false, 15 | play_stop: true, 16 | prev: false, 17 | next: false, 18 | jump: true, 19 | state_label: false, 20 | progress: false, 21 | icon: false, 22 | name: false, 23 | info: false, 24 | }; 25 | 26 | const REPEAT_STATE = { 27 | OFF: 'off', 28 | ALL: 'all', 29 | ONE: 'one', 30 | }; 31 | 32 | const ICON = { 33 | DROPDOWN: 'mdi:chevron-down', 34 | GROUP: 'mdi:speaker-multiple', 35 | MENU: 'mdi:menu-down', 36 | MUTE: { 37 | true: 'mdi:volume-off', 38 | false: 'mdi:volume-high', 39 | }, 40 | NEXT: 'mdi:skip-next', 41 | PLAY: { 42 | true: 'mdi:pause', 43 | false: 'mdi:play', 44 | }, 45 | POWER: 'mdi:power', 46 | PREV: 'mdi:skip-previous', 47 | SEND: 'mdi:send', 48 | SHUFFLE: 'mdi:shuffle', 49 | REPEAT: { 50 | [REPEAT_STATE.OFF]: 'mdi:repeat-off', 51 | [REPEAT_STATE.ONE]: 'mdi:repeat-once', 52 | [REPEAT_STATE.ALL]: 'mdi:repeat', 53 | }, 54 | STOP: { 55 | true: 'mdi:stop', 56 | false: 'mdi:play', 57 | }, 58 | VOL_DOWN: 'mdi:volume-minus', 59 | VOL_UP: 'mdi:volume-plus', 60 | FAST_FORWARD: 'mdi:fast-forward', 61 | REWIND: 'mdi:rewind', 62 | }; 63 | 64 | const UPDATE_PROPS = [ 65 | 'entity', 66 | 'groupMgmtEntity', 67 | '_overflow', 68 | 'break', 69 | 'thumbnail', 70 | 'prevThumbnail', 71 | 'edit', 72 | 'idle', 73 | 'cardHeight', 74 | 'backgroundColor', 75 | 'foregroundColor', 76 | ]; 77 | 78 | const PROGRESS_PROPS = ['media_duration', 'media_position', 'media_position_updated_at']; 79 | 80 | const BREAKPOINT = 390; 81 | 82 | const LABEL_SHORTCUT = 'Shortcuts...'; 83 | 84 | const MEDIA_INFO = [ 85 | { attr: 'media_title' }, 86 | { attr: 'media_artist' }, 87 | { attr: 'media_series_title' }, 88 | { attr: 'media_season', prefix: 'S' }, 89 | { attr: 'media_episode', prefix: 'E' }, 90 | { attr: 'media_channel' }, 91 | { attr: 'app_name' }, 92 | ]; 93 | 94 | const PLATFORM = { 95 | SONOS: 'sonos', 96 | SQUEEZEBOX: 'squeezebox', 97 | BLUESOUND: 'bluesound', 98 | SOUNDTOUCH: 'soundtouch', 99 | MEDIAPLAYER: 'media_player', 100 | HEOS: 'heos', 101 | }; 102 | 103 | const CONTRAST_RATIO = 4.5; 104 | 105 | const COLOR_SIMILARITY_THRESHOLD = 150; 106 | 107 | export { 108 | DEFAULT_HIDE, 109 | ICON, 110 | UPDATE_PROPS, 111 | PROGRESS_PROPS, 112 | BREAKPOINT, 113 | LABEL_SHORTCUT, 114 | MEDIA_INFO, 115 | PLATFORM, 116 | CONTRAST_RATIO, 117 | COLOR_SIMILARITY_THRESHOLD, 118 | REPEAT_STATE, 119 | }; 120 | -------------------------------------------------------------------------------- /src/editor.js: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css } from 'lit-element'; 2 | import style from './style'; 3 | import generateConfig from './config/config'; 4 | import './components/dropdown'; 5 | 6 | const fireEvent = (node, type, detail = {}, options = {}) => { 7 | const event = new Event(type, { 8 | bubbles: options.bubbles === undefined ? true : options.bubbles, 9 | cancelable: Boolean(options.cancelable), 10 | composed: options.composed === undefined ? true : options.composed, 11 | }); 12 | 13 | event.detail = detail; 14 | node.dispatchEvent(event); 15 | return event; 16 | }; 17 | 18 | const OptionsArtwork = ['cover', 'full-cover', 'material', 'cover-fit', 'none']; 19 | 20 | const OptionsSource = ['icon', 'full']; 21 | 22 | const OptionsSoundMode = ['icon', 'full']; 23 | 24 | const OptionsInfo = ['short', 'scroll']; 25 | 26 | const OptionsReplaceMute = ['play_pause', 'stop', 'play_stop', 'next']; 27 | 28 | const computeItems = (options, optional = false) => { 29 | const items = options.map((option) => ({ 30 | name: option, 31 | id: option, 32 | })); 33 | 34 | if (optional) { 35 | items.push({ name: 'Default', id: undefined }); 36 | } 37 | 38 | return items; 39 | }; 40 | 41 | export default class MiniMediaPlayerEditor extends LitElement { 42 | static get styles() { 43 | return [ 44 | style, 45 | css` 46 | .editor-side-by-side { 47 | display: flex; 48 | margin: 16px 0; 49 | } 50 | .editor-side-by-side > * { 51 | flex: 1; 52 | padding-right: 4px; 53 | } 54 | .editor-label { 55 | margin-left: 6px; 56 | font-size: 0.8em; 57 | opacity: 0.75; 58 | } 59 | `, 60 | ]; 61 | } 62 | 63 | static get properties() { 64 | return { hass: {}, _config: {} }; 65 | } 66 | 67 | setConfig(config) { 68 | this._config = Object.assign({}, generateConfig, config); 69 | } 70 | 71 | get getMediaPlayerEntities() { 72 | return Object.keys(this.hass.states).filter((eid) => eid.substr(0, eid.indexOf('.')) === 'media_player'); 73 | } 74 | 75 | get _group() { 76 | return this._config.group || false; 77 | } 78 | 79 | // eslint-disable-next-line camelcase 80 | get _volume_stateless() { 81 | return this._config.volume_stateless || false; 82 | } 83 | 84 | // eslint-disable-next-line camelcase 85 | get _toggle_power() { 86 | return this._config.toggle_power || true; 87 | } 88 | 89 | render() { 90 | if (!this.hass) return html``; 91 | 92 | const mediaPlayerOptions = this.getMediaPlayerEntities.map((entity) => ({ 93 | name: entity, 94 | id: entity, 95 | })); 96 | 97 | return html` 98 |
99 |
100 | Entity (required) 101 | this.valueChanged({ target: { configValue: 'entity', value: detail.id } })} 104 | .items=${mediaPlayerOptions} 105 | .label=${'Select entity'} 106 | .selected=${this._config.entity} 107 | > 108 | 109 | 110 |
111 | 117 | 118 | 124 | 125 | 131 |
132 | 133 |
134 | 135 | 136 | 137 | 138 | 139 | 144 | 145 | 146 | 147 | 152 | 153 |
154 | 155 |
156 |
157 | Artwork 158 | this.valueChanged({ target: { configValue: 'artwork', value: detail.id } })} 161 | .items=${computeItems(OptionsArtwork, true)} 162 | .label=${'Default'} 163 | .selected=${this._config.artwork} 164 | > 165 | 166 |
167 |
168 | Source 169 | this.valueChanged({ target: { configValue: 'source', value: detail.id } })} 172 | .items=${computeItems(OptionsSource, true)} 173 | .label=${'Default'} 174 | .selected=${this._config.source} 175 | > 176 | 177 |
178 |
179 | Sound mode 180 | 183 | this.valueChanged({ target: { configValue: 'sound_mode', value: detail.id } })} 184 | .items=${computeItems(OptionsSoundMode, true)} 185 | .label=${'Default'} 186 | .selected=${this._config.sound_mode} 187 | > 188 | 189 |
190 |
191 | 192 |
193 |
194 | Info 195 | this.valueChanged({ target: { configValue: 'info', value: detail.id } })} 198 | .items=${computeItems(OptionsInfo, true)} 199 | .label=${'Default'} 200 | .selected=${this._config.info} 201 | > 202 | 203 |
204 | 205 |
206 | Replace Mute 207 | 210 | this.valueChanged({ target: { configValue: 'replace_mute', value: detail.id } })} 211 | .items=${computeItems(OptionsReplaceMute, true)} 212 | .label=${'Default'} 213 | .selected=${this._config.replace_mute} 214 | > 215 | 216 |
217 |
218 | 219 |
220 | 226 | 227 | 233 | 234 | 240 |
241 | 242 |
243 | 249 | 250 | 256 |
257 | 258 |
259 | Settings for Tap actions, TTS, hiding UI elements, idle view, speaker groups and shortcuts can only be 260 | configured in the code editor 261 |
262 |
263 |
264 | `; 265 | } 266 | 267 | valueChanged(ev) { 268 | if (!this._config || !this.hass) { 269 | return; 270 | } 271 | const { target } = ev; 272 | if (this[`_${target.configValue}`] === target.value) { 273 | return; 274 | } 275 | if (target.configValue) { 276 | if (target.value === '') { 277 | delete this._config[target.configValue]; 278 | } else { 279 | this._config = { 280 | ...this._config, 281 | [target.configValue]: target.checked !== undefined ? target.checked : target.value, 282 | }; 283 | } 284 | } 285 | fireEvent(this, 'config-changed', { config: this._config }); 286 | } 287 | } 288 | 289 | customElements.define('mini-media-player-editor', MiniMediaPlayerEditor); 290 | -------------------------------------------------------------------------------- /src/ensureComponents.ts: -------------------------------------------------------------------------------- 1 | if (!customElements.get('ha-slider')) { 2 | customElements.define('ha-slider', class extends (customElements.get('paper-slider') as CustomElementConstructor) {}); 3 | } 4 | 5 | if (!customElements.get('ha-icon-button')) { 6 | customElements.define( 7 | 'ha-icon-button', 8 | class extends (customElements.get('paper-icon-button') as CustomElementConstructor) {}, 9 | ); 10 | } 11 | 12 | if (!customElements.get('ha-icon')) { 13 | customElements.define('ha-icon', class extends (customElements.get('iron-icon') as CustomElementConstructor) {}); 14 | } 15 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 2 | // // @ts-nocheck 3 | import { 4 | LitElement, 5 | html, 6 | customElement, 7 | property, 8 | state, 9 | CSSResultGroup, 10 | PropertyValues, 11 | TemplateResult, 12 | } from 'lit-element'; 13 | import { classMap } from 'lit-html/directives/class-map'; 14 | import { styleMap } from 'lit-html/directives/style-map'; 15 | import ResizeObserver from 'resize-observer-polyfill'; 16 | 17 | import { generateConfig } from './config/config'; 18 | import MediaPlayerObject from './model'; 19 | import style from './style'; 20 | import sharedStyle from './sharedStyle'; 21 | import handleClick from './utils/handleClick'; 22 | import colorsFromPicture from './utils/colorGenerator'; 23 | 24 | import './ensureComponents'; 25 | 26 | import './components/groupList'; 27 | import './components/dropdown'; 28 | import './components/shortcuts'; 29 | import './components/tts'; 30 | import './components/progress'; 31 | import './components/powerstrip'; 32 | import './components/mediaControls'; 33 | 34 | import { ICON, UPDATE_PROPS, BREAKPOINT } from './const'; 35 | import { HomeAssistant, MediaPlayerEntity } from './types'; 36 | import { Part } from 'lit-html'; 37 | import { MiniMediaPlayerBaseConfiguration, MiniMediaPlayerConfiguration } from './config/types'; 38 | 39 | @customElement('mini-media-player') 40 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 41 | class MiniMediaPlayer extends LitElement { 42 | @property({ attribute: false }) 43 | set hass(hass) { 44 | if (!hass) return; 45 | const entity = hass.states[this.config.entity] as MediaPlayerEntity; 46 | this._hass = hass; 47 | if (entity && this.entity !== entity) { 48 | this.entity = entity; 49 | this.player = new MediaPlayerObject(hass, this.config, entity); 50 | this.rtl = this.computeRTL(hass); 51 | this.idle = this.player.idle; 52 | if (this.player.trackIdle) this.updateIdleStatus(); 53 | } 54 | if (this.config && this.config.speaker_group && this.config.speaker_group.group_mgmt_entity) { 55 | const altPlayer = hass.states[this.config.speaker_group.group_mgmt_entity] as MediaPlayerEntity; 56 | if (altPlayer && this.groupMgmtEntity !== altPlayer) { 57 | this.groupMgmtEntity = altPlayer; 58 | this.groupMgmtPlayer = new MediaPlayerObject(hass, this.config, altPlayer); 59 | } 60 | } 61 | } 62 | 63 | get hass(): HomeAssistant { 64 | return this._hass; 65 | } 66 | 67 | @state() private _overflow?: number; 68 | @state() private initial = true; 69 | @state() private picture?: string = undefined; 70 | @state() private thumbnail = ''; 71 | @state() private prevThumbnail = ''; 72 | @state() private edit = false; 73 | @state() private rtl = false; 74 | @state() private cardHeight = 0; 75 | @state() private foregroundColor = ''; 76 | @state() private backgroundColor = ''; 77 | 78 | @state() private config!: MiniMediaPlayerConfiguration; 79 | @state() private _hass!: HomeAssistant; 80 | @state() private entity?: MediaPlayerEntity; 81 | @state() private player!: MediaPlayerObject; 82 | @state() private idle!: boolean; 83 | @state() private groupMgmtPlayer?: MediaPlayerObject; 84 | @state() private groupMgmtEntity?: MediaPlayerEntity; 85 | @state() private break = false; 86 | @state() private _resizeEntry?: ResizeObserverEntry; 87 | @state() private _resizeTimer?: NodeJS.Timeout; 88 | @state() private _idleTracker?: NodeJS.Timeout; 89 | 90 | public static async getConfigElement() { 91 | await import('./editor'); 92 | return document.createElement('mini-media-player-editor'); 93 | } 94 | 95 | static get styles(): CSSResultGroup { 96 | return [sharedStyle, style]; 97 | } 98 | 99 | set overflow(value: number | undefined) { 100 | if (this._overflow !== value) this._overflow = value; 101 | } 102 | 103 | get overflow(): number | undefined { 104 | return this._overflow; 105 | } 106 | 107 | get name(): string { 108 | return this.config.name || this.player.name; 109 | } 110 | 111 | setConfig(config: MiniMediaPlayerBaseConfiguration) { 112 | this.config = generateConfig(config); 113 | } 114 | 115 | protected shouldUpdate(changedProps: PropertyValues): boolean { 116 | if (this.break === undefined) this.computeRect(this); 117 | if (changedProps.has('prevThumbnail') && this.prevThumbnail) { 118 | setTimeout(() => { 119 | this.prevThumbnail = ''; 120 | }, 1000); 121 | } 122 | if (changedProps.has('player') && this.config.artwork === 'material') { 123 | this.setColors(); 124 | } 125 | return UPDATE_PROPS.some((prop) => changedProps.has(prop)) && Boolean(this.player); 126 | } 127 | 128 | protected firstUpdated() { 129 | const ro = new ResizeObserver((entries: ResizeObserverEntry[]) => { 130 | entries.forEach((entry) => { 131 | window.requestAnimationFrame(() => { 132 | if (this.config.info === 'scroll') this.computeOverflow(); 133 | if (!this._resizeTimer) { 134 | this.computeRect(entry); 135 | this._resizeTimer = setTimeout(() => { 136 | this._resizeTimer = undefined; 137 | if (this._resizeEntry) { 138 | this.computeRect(this._resizeEntry); 139 | this.measureCard(); 140 | } 141 | }, 250); 142 | } 143 | this._resizeEntry = entry; 144 | }); 145 | }); 146 | }); 147 | ro.observe(this); 148 | 149 | setTimeout(() => (this.initial = false), 250); 150 | this.edit = this.config.speaker_group.expanded || false; 151 | } 152 | 153 | protected updated() { 154 | if (this.config.info === 'scroll') 155 | setTimeout(() => { 156 | this.computeOverflow(); 157 | }, 10); 158 | } 159 | 160 | render({ config } = this): TemplateResult | void { 161 | this.computeArtwork(); 162 | 163 | return html` 164 | this.handlePopup(e)} 168 | artwork=${config.artwork} 169 | content=${this.player.content} 170 | > 171 |
${this.renderBackground()} ${this.renderArtwork()} ${this.renderGradient()}
172 |
173 |
174 | ${this.renderIcon()} 175 |
${this.renderEntityName()} ${this.renderMediaInfo()}
176 | 185 | 186 |
187 |
188 | ${!config.collapse && this.player.isActive 189 | ? html` 190 | 191 | 192 | ` 193 | : ''} 194 | 195 | ${config.tts 196 | ? html` ` 197 | : ''} 198 | > 204 | 205 |
206 |
207 |
208 | ${this.player.isActive && this.player.hasProgress 209 | ? html` 210 | 215 | 216 | ` 217 | : ''} 218 |
219 |
220 | `; 221 | } 222 | 223 | computeClasses({ config } = this) { 224 | return classMap({ 225 | '--responsive': this.break || config.hide.icon, 226 | '--initial': this.initial, 227 | '--bg': config.background || false, 228 | '--group': config.group, 229 | '--more-info': config.tap_action.action !== 'none', 230 | '--has-artwork': this.player.hasArtwork && this.thumbnail, 231 | '--flow': config.flow, 232 | '--collapse': config.collapse, 233 | '--rtl': this.rtl, 234 | '--progress': this.player.hasProgress, 235 | '--runtime': !config.hide.runtime && this.player.hasProgress, 236 | '--inactive': !this.player.isActive, 237 | }); 238 | } 239 | 240 | renderArtwork(): TemplateResult | undefined { 241 | if (!this.thumbnail || this.config.artwork === 'default') return; 242 | 243 | const artworkStyle = { 244 | backgroundImage: this.thumbnail, 245 | backgroundColor: this.backgroundColor || '', 246 | width: this.config.artwork === 'material' && this.player.isActive ? `${this.cardHeight}px` : '100%', 247 | }; 248 | const artworkPrevStyle = { 249 | backgroundImage: this.prevThumbnail, 250 | width: this.config.artwork === 'material' ? `${this.cardHeight}px` : '', 251 | }; 252 | 253 | return html`
254 | ${this.prevThumbnail && html`
`}`; 255 | } 256 | 257 | renderGradient(): TemplateResult | undefined { 258 | if (this.config.artwork !== 'material') return; 259 | 260 | const gradientStyle = { 261 | backgroundImage: `linear-gradient(to left, 262 | transparent 0, 263 | ${this.backgroundColor} ${this.cardHeight}px, 264 | ${this.backgroundColor} 100%)`, 265 | }; 266 | 267 | return html`
`; 268 | } 269 | 270 | renderBackground(): TemplateResult | undefined { 271 | if (!this.config.background) return; 272 | 273 | return html` 274 |
275 | `; 276 | } 277 | 278 | handlePopup(e: MouseEvent) { 279 | e.stopPropagation(); 280 | handleClick(this, this._hass, this.config, this.config.tap_action, this.player.id); 281 | } 282 | 283 | renderIcon(): TemplateResult | undefined { 284 | if (this.config.hide.icon) return; 285 | if (this.player.isActive && this.thumbnail && this.config.artwork === 'default') { 286 | return html`
292 | ${' '} 293 |
`; 294 | } 295 | 296 | if (this.config.icon_image != undefined){ 297 | return html`
298 | 299 |
`; 300 | } 301 | 302 | const active = !this.config.hide.icon_state && this.player.isActive; 303 | return html`
304 | 310 |
`; 311 | } 312 | 313 | renderEntityName(): TemplateResult | undefined { 314 | if (this.config.hide.name) return; 315 | 316 | return html`
${this.name} ${this.speakerCount()}
`; 317 | } 318 | 319 | renderMediaInfo(): TemplateResult | undefined { 320 | if (this.config.hide.info) return; 321 | const items = this.player.mediaInfo; 322 | 323 | return html`
330 | ${this.config.info === 'scroll' 331 | ? html`
332 |
333 | ${items.map((i) => html`${i.prefix + i.text}`)} 334 |
335 |
` 336 | : ''} 337 | ${items.map((i) => html`${i.prefix + i.text}`)} 338 |
`; 339 | } 340 | 341 | speakerCount(): string | undefined { 342 | if (this.config.speaker_group.show_group_count) { 343 | const count = this.groupMgmtPlayer ? this.groupMgmtPlayer.groupCount : this.player.groupCount; 344 | return count > 1 ? ` +${count - 1}` : ''; 345 | } 346 | return; 347 | } 348 | 349 | computeStyles(): (part: Part) => void { 350 | const { scale } = this.config; 351 | return styleMap({ 352 | ...(scale && { '--mmp-unit': `${40 * scale}px` }), 353 | ...(this.foregroundColor && 354 | this.player.isActive && { 355 | '--mmp-text-color': this.foregroundColor, 356 | '--mmp-icon-color': this.foregroundColor, 357 | '--mmp-icon-active-color': this.foregroundColor, 358 | '--mmp-accent-color': this.foregroundColor, 359 | '--paper-slider-container-color': this.foregroundColor, 360 | '--secondary-text-color': this.foregroundColor, 361 | '--mmp-media-cover-info-color': this.foregroundColor, 362 | }), 363 | }); 364 | } 365 | 366 | async computeArtwork(): Promise { 367 | const { picture, hasArtwork } = this.player; 368 | if (hasArtwork && picture !== this.picture) { 369 | this.picture = picture; 370 | const artwork = await this.player.fetchArtwork(); 371 | if (this.thumbnail) { 372 | this.prevThumbnail = this.thumbnail; 373 | } 374 | this.thumbnail = artwork || `url(${picture})`; 375 | } 376 | } 377 | 378 | measureCard(): void { 379 | const card = this.shadowRoot?.querySelector('ha-card') as HTMLElement | undefined; 380 | if (!card) { 381 | return; 382 | } 383 | 384 | this.cardHeight = card.offsetHeight; 385 | } 386 | 387 | computeOverflow(): void { 388 | const element = this.shadowRoot?.querySelector('.marquee') as HTMLElement | undefined; 389 | if (element && element.parentNode) { 390 | const status = element.clientWidth > (element.parentNode as HTMLElement).clientWidth; 391 | this.overflow = status && this.player.isActive ? 7.5 + element.clientWidth / 50 : undefined; 392 | } 393 | } 394 | 395 | private computeRect(entry: ResizeObserverEntry | HTMLElement) { 396 | if ('contentRect' in entry) { 397 | const { left, width } = entry.contentRect; 398 | this.break = width + left * 2 < BREAKPOINT; 399 | } else { 400 | const { left, width } = entry.getBoundingClientRect(); 401 | this.break = width + left * 2 < BREAKPOINT; 402 | } 403 | } 404 | 405 | computeRTL(hass: HomeAssistant): boolean { 406 | const lang = hass.language || 'en'; 407 | if (hass.translationMetadata.translations[lang]) { 408 | return hass.translationMetadata.translations[lang].isRTL || false; 409 | } 410 | return false; 411 | } 412 | 413 | toggleGroupList(): void { 414 | this.edit = !this.edit; 415 | } 416 | 417 | updateIdleStatus(): void { 418 | const delay = this.config?.idle_view?.after; 419 | if (!delay) { 420 | return; 421 | } 422 | 423 | if (this._idleTracker) clearTimeout(this._idleTracker); 424 | const diff = (Date.now() - new Date(this.player.updatedAt).getTime()) / 1000; 425 | this._idleTracker = setTimeout(() => { 426 | this.idle = this.player.checkIdleAfter(delay); 427 | this.player.idle = this.idle; 428 | this._idleTracker = undefined; 429 | }, (delay * 60 - diff) * 1000); 430 | } 431 | 432 | public getCardSize(): number { 433 | return this.config.collapse ? 1 : 2; 434 | } 435 | 436 | async setColors(): Promise { 437 | if (this.player.picture === this.picture) return; 438 | 439 | if (!this.player.picture) { 440 | this.foregroundColor = ''; 441 | this.backgroundColor = ''; 442 | return; 443 | } 444 | 445 | try { 446 | [this.foregroundColor, this.backgroundColor] = await colorsFromPicture(this.player.picture); 447 | } catch (err) { 448 | // eslint-disable-next-line no-console 449 | console.error('Error getting Image Colors', err); 450 | this.foregroundColor = ''; 451 | this.backgroundColor = ''; 452 | } 453 | } 454 | } 455 | 456 | // Configures the preview in the Lovelace card picker 457 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 458 | (window as any).customCards = (window as any).customCards || []; 459 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 460 | (window as any).customCards.push({ 461 | type: 'mini-media-player', 462 | name: 'Mini Media Player', 463 | preview: false, 464 | description: 'A minimalistic yet customizable media player card', 465 | }); 466 | -------------------------------------------------------------------------------- /src/model.ts: -------------------------------------------------------------------------------- 1 | import { MiniMediaPlayerConfiguration } from './config/types'; 2 | import { PROGRESS_PROPS, MEDIA_INFO, PLATFORM, REPEAT_STATE } from './const'; 3 | import { HomeAssistant, MediaPlayerEntity, MediaPlayerEntityAttributes, MediaPlayerEntityState } from './types'; 4 | import arrayBufferToBase64 from './utils/misc'; 5 | 6 | export interface MediaPlayerMedia { 7 | media_content_type: string; 8 | media_content_id: string; 9 | } 10 | 11 | export default class MediaPlayerObject { 12 | hass: HomeAssistant; 13 | config: MiniMediaPlayerConfiguration; 14 | entity: MediaPlayerEntity; 15 | 16 | state: MediaPlayerEntityState; 17 | idle: boolean; 18 | 19 | _entityId: string; 20 | _attr: MediaPlayerEntityAttributes; 21 | _active: boolean; 22 | 23 | constructor(hass: HomeAssistant, config: MiniMediaPlayerConfiguration, entity: MediaPlayerEntity) { 24 | this.hass = hass || {}; 25 | this.config = config || {}; 26 | this.entity = entity || {}; 27 | this.state = entity.state; 28 | this._entityId = (entity && entity.entity_id) || this.config.entity; 29 | this._attr = entity.attributes || {}; 30 | this.idle = config.idle_view ? this.idleView : false; 31 | this._active = this.isActive; 32 | } 33 | 34 | get id(): string { 35 | return this.entity.entity_id; 36 | } 37 | 38 | get icon(): string | undefined { 39 | return this._attr.icon; 40 | } 41 | 42 | get isPaused(): boolean { 43 | return this.state === MediaPlayerEntityState.PAUSED; 44 | } 45 | 46 | get isPlaying(): boolean { 47 | return this.state === MediaPlayerEntityState.PLAYING; 48 | } 49 | 50 | get isIdle(): boolean { 51 | return this.state === MediaPlayerEntityState.IDLE; 52 | } 53 | 54 | get isStandby(): boolean { 55 | return this.state === MediaPlayerEntityState.STANDBY; 56 | } 57 | 58 | get isUnavailable(): boolean { 59 | return this.state === MediaPlayerEntityState.UNAVAILABLE; 60 | } 61 | 62 | get isOff(): boolean { 63 | return this.state === MediaPlayerEntityState.OFF; 64 | } 65 | 66 | get isActive(): boolean { 67 | return (!this.isOff && !this.isUnavailable && !this.idle) || false; 68 | } 69 | 70 | get assumedState(): boolean { 71 | return this._attr.assumed_state || false; 72 | } 73 | 74 | get shuffle(): boolean { 75 | return this._attr.shuffle || false; 76 | } 77 | 78 | get repeat(): string { 79 | return this._attr.repeat || REPEAT_STATE.OFF; 80 | } 81 | 82 | get content(): string { 83 | return this._attr.media_content_type || 'none'; 84 | } 85 | 86 | get mediaDuration(): string | number | Date { 87 | return this._attr.media_duration || 0; 88 | } 89 | 90 | get updatedAt(): string | number | Date { 91 | return this._attr.media_position_updated_at || 0; 92 | } 93 | 94 | get position(): number { 95 | return this._attr.media_position || 0; 96 | } 97 | 98 | get name(): string { 99 | return this._attr.friendly_name || ''; 100 | } 101 | 102 | get groupCount(): number { 103 | return this.group.length; 104 | } 105 | 106 | get isGrouped(): boolean { 107 | return this.group.length > 1; 108 | } 109 | 110 | get group(): string[] { 111 | if (this.platform === PLATFORM.SQUEEZEBOX) { 112 | return this._attr.sync_group || []; 113 | } 114 | if (this.platform === PLATFORM.MEDIAPLAYER || this.platform === PLATFORM.HEOS 115 | || this.platform === PLATFORM.SONOS) { 116 | return this._attr.group_members || []; 117 | } 118 | return (this._attr[`${this.platform}_group`] || []) as string[]; 119 | } 120 | 121 | get platform(): string { 122 | return this.config.speaker_group.platform; 123 | } 124 | 125 | get master(): string { 126 | return this.supportsMaster ? this.group[0] || this._entityId : this._entityId; 127 | } 128 | 129 | get isMaster(): boolean { 130 | return this.master === this._entityId; 131 | } 132 | 133 | get sources(): string[] { 134 | return this._attr.source_list || []; 135 | } 136 | 137 | get source(): string { 138 | return this._attr.source || ''; 139 | } 140 | 141 | get soundModes(): string[] { 142 | return this._attr.sound_mode_list || []; 143 | } 144 | 145 | get soundMode(): string { 146 | return this._attr.sound_mode || ''; 147 | } 148 | 149 | get muted(): boolean { 150 | return this._attr.is_volume_muted || false; 151 | } 152 | 153 | get vol(): number { 154 | return this._attr.volume_level || 0; 155 | } 156 | 157 | get picture(): string | undefined { 158 | return this._attr.entity_picture_local || this._attr.entity_picture; 159 | } 160 | 161 | get hasArtwork(): boolean { 162 | return !!this.picture && this.config.artwork !== 'none' && this._active && !this.idle; 163 | } 164 | 165 | get mediaInfo(): { 166 | text: string; 167 | prefix: string; 168 | attr: string; 169 | }[] { 170 | return MEDIA_INFO.map((item) => ({ 171 | text: this._attr[item.attr], 172 | prefix: '', 173 | ...item, 174 | })).filter((item) => item.text); 175 | } 176 | 177 | get hasProgress(): boolean { 178 | return !this.config.hide.progress && !this.idle && PROGRESS_PROPS.every((prop) => prop in this._attr); 179 | } 180 | 181 | get supportsPrev(): boolean { 182 | return !!this._attr.supported_features && (this._attr.supported_features | 16) === this._attr.supported_features; 183 | } 184 | 185 | get supportsNext(): boolean { 186 | return !!this._attr.supported_features && (this._attr.supported_features | 32) === this._attr.supported_features; 187 | } 188 | 189 | get progress(): number { 190 | if (this.isPlaying) { 191 | return this.position + (Date.now() - new Date(this.updatedAt).getTime()) / 1000.0; 192 | } else { 193 | return this.position; 194 | } 195 | } 196 | 197 | get idleView(): boolean { 198 | const idle = this.config.idle_view; 199 | if ( 200 | (idle?.when_idle && this.isIdle) || 201 | (idle?.when_standby && this.isStandby) || 202 | (idle?.when_paused && this.isPaused) 203 | ) 204 | return true; 205 | 206 | // TODO: remove? 207 | if (!this.updatedAt || !idle?.after || this.isPlaying) return false; 208 | 209 | return this.checkIdleAfter(idle.after); 210 | } 211 | 212 | get trackIdle(): boolean { 213 | return Boolean(this._active && !this.isPlaying && this.updatedAt && this.config?.idle_view?.after); 214 | } 215 | 216 | public checkIdleAfter(time: number): boolean { 217 | const diff = (Date.now() - new Date(this.updatedAt).getTime()) / 1000; 218 | this.idle = diff > time * 60; 219 | this._active = this.isActive; 220 | return this.idle; 221 | } 222 | 223 | get supportsShuffle(): boolean { 224 | return typeof this._attr.shuffle !== 'undefined'; 225 | } 226 | 227 | get supportsRepeat(): boolean { 228 | return typeof this._attr.repeat !== 'undefined'; 229 | } 230 | 231 | get supportsMute(): boolean { 232 | return typeof this._attr.is_volume_muted !== 'undefined'; 233 | } 234 | 235 | get supportsVolumeSet(): boolean { 236 | return typeof this._attr.volume_level !== 'undefined'; 237 | } 238 | 239 | get supportsMaster(): boolean { 240 | return this.platform !== PLATFORM.SQUEEZEBOX && this.config.speaker_group.supports_master; 241 | } 242 | 243 | async fetchArtwork(): Promise { 244 | const url = this._attr.entity_picture_local ? this.hass.hassUrl(this.picture) : this.picture; 245 | 246 | try { 247 | const res = await fetch(new Request(url)); 248 | const buffer = await res.arrayBuffer(); 249 | const image64 = arrayBufferToBase64(buffer); 250 | const imageType = res.headers.get('Content-Type') || 'image/jpeg'; 251 | return `url(data:${imageType};base64,${image64})`; 252 | } catch (error) { 253 | return false; 254 | } 255 | } 256 | 257 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 258 | getAttribute(attribute: keyof MediaPlayerEntityAttributes): any { 259 | return this._attr[attribute]; 260 | } 261 | 262 | toggle(e: MouseEvent): void { 263 | if (this.config.toggle_power) return this.callService(e, 'toggle'); 264 | if (this.isOff) return this.callService(e, 'turn_on'); 265 | else this.callService(e, 'turn_off'); 266 | } 267 | 268 | toggleMute(e: MouseEvent): void { 269 | if (this.config.speaker_group.sync_volume) { 270 | this.group.forEach((entity) => { 271 | this.callService(e, 'volume_mute', { 272 | entity_id: entity, 273 | is_volume_muted: !this.muted, 274 | }); 275 | }); 276 | } else { 277 | this.callService(e, 'volume_mute', { is_volume_muted: !this.muted }); 278 | } 279 | } 280 | 281 | toggleShuffle(e: MouseEvent): void { 282 | this.callService(e, 'shuffle_set', { shuffle: !this.shuffle }); 283 | } 284 | 285 | toggleRepeat(e: MouseEvent): void { 286 | const states = Object.values(REPEAT_STATE); 287 | const { length } = states; 288 | const currentIndex = states.indexOf(this.repeat) - 1; 289 | const nextState = states[(currentIndex - (1 % length) + length) % length]; 290 | this.callService(e, 'repeat_set', { repeat: nextState }); 291 | } 292 | 293 | setSource(e: Event, source: string): void { 294 | this.callService(e, 'select_source', { source }); 295 | } 296 | 297 | // TODO: fix opts type 298 | setMedia(e: MouseEvent, opts: MediaPlayerMedia): void { 299 | this.callService(e, 'play_media', { ...opts }); 300 | } 301 | 302 | play(e: MouseEvent): void { 303 | this.callService(e, 'media_play'); 304 | } 305 | 306 | pause(e: MouseEvent): void { 307 | this.callService(e, 'media_pause'); 308 | } 309 | 310 | playPause(e: MouseEvent): void { 311 | this.callService(e, 'media_play_pause'); 312 | } 313 | 314 | playStop(e: MouseEvent): void { 315 | if (!this.isPlaying) this.callService(e, 'media_play'); 316 | else this.callService(e, 'media_stop'); 317 | } 318 | 319 | setSoundMode(e: Event, name: string): void { 320 | this.callService(e, 'select_sound_mode', { sound_mode: name }); 321 | } 322 | 323 | next(e: MouseEvent): void { 324 | this.callService(e, 'media_next_track'); 325 | } 326 | 327 | prev(e: MouseEvent): void { 328 | this.callService(e, 'media_previous_track'); 329 | } 330 | 331 | stop(e: MouseEvent): void { 332 | this.callService(e, 'media_stop'); 333 | } 334 | 335 | volumeUp(e: MouseEvent): void { 336 | if (this.supportsVolumeSet && this.config.volume_step && this.config.volume_step > 0) { 337 | this.callService(e, 'volume_set', { 338 | entity_id: this._entityId, 339 | volume_level: Math.min(this.vol + this.config.volume_step / 100, 1), 340 | }); 341 | } else this.callService(e, 'volume_up'); 342 | } 343 | 344 | volumeDown(e: MouseEvent): void { 345 | if (this.supportsVolumeSet && this.config.volume_step && this.config.volume_step > 0) { 346 | this.callService(e, 'volume_set', { 347 | entity_id: this._entityId, 348 | volume_level: Math.max(this.vol - this.config.volume_step / 100, 0), 349 | }); 350 | } else this.callService(e, 'volume_down'); 351 | } 352 | 353 | seek(e: MouseEvent, pos: number): void { 354 | this.callService(e, 'media_seek', { seek_position: pos }); 355 | } 356 | 357 | jump(e: MouseEvent, amount: number): void { 358 | const newPosition = this.progress + amount; 359 | const clampedNewPosition = Math.min(Math.max(newPosition, 0), Number(this.mediaDuration) || newPosition); 360 | this.callService(e, 'media_seek', { seek_position: clampedNewPosition }); 361 | } 362 | 363 | setVolume(e: MouseEvent, volume: number): void { 364 | if (this.config.speaker_group.sync_volume && this.config.speaker_group.entities) { 365 | this.group.forEach((entity) => { 366 | const conf = this.config.speaker_group.entities?.find((entry) => entry.entity_id === entity); 367 | 368 | if (typeof conf === 'undefined') return; 369 | 370 | let offsetVolume = volume; 371 | if (conf.volume_offset) { 372 | offsetVolume += conf.volume_offset / 100; 373 | if (offsetVolume > 1) offsetVolume = 1; 374 | if (offsetVolume < 0) offsetVolume = 0; 375 | } 376 | this.callService(e, 'volume_set', { 377 | entity_id: entity, 378 | volume_level: offsetVolume, 379 | }); 380 | }); 381 | } else { 382 | this.callService(e, 'volume_set', { 383 | entity_id: this._entityId, 384 | volume_level: volume, 385 | }); 386 | } 387 | } 388 | 389 | handleGroupChange(e: Event, entity: string | string[], checked: boolean): void { 390 | const { platform } = this; 391 | const options: { entity_id: string | string[]; master?: string } = { entity_id: entity }; 392 | if (checked) { 393 | options.master = this._entityId; 394 | switch (platform) { 395 | case PLATFORM.SOUNDTOUCH: 396 | return this.handleSoundtouch(e, this.isGrouped ? 'ADD_ZONE_SLAVE' : 'CREATE_ZONE', entity); 397 | case PLATFORM.SQUEEZEBOX: 398 | return this.callService( 399 | e, 400 | 'sync', 401 | { 402 | entity_id: this._entityId, 403 | other_player: entity, 404 | }, 405 | PLATFORM.SQUEEZEBOX, 406 | ); 407 | case PLATFORM.MEDIAPLAYER: 408 | case PLATFORM.SONOS: 409 | return this.callService( 410 | e, 411 | 'join', 412 | { 413 | entity_id: this._entityId, 414 | group_members: entity, 415 | }, 416 | PLATFORM.MEDIAPLAYER, 417 | ); 418 | case PLATFORM.HEOS: 419 | return this.callService( 420 | e, 421 | 'join', 422 | { 423 | entity_id: this._entityId, 424 | group_members: this.group.concat(typeof entity === 'string' ? [entity] : entity), 425 | }, 426 | PLATFORM.MEDIAPLAYER, 427 | ); 428 | default: 429 | return this.callService(e, 'join', options, platform); 430 | } 431 | } else { 432 | switch (platform) { 433 | case PLATFORM.SOUNDTOUCH: 434 | return this.handleSoundtouch(e, 'REMOVE_ZONE_SLAVE', entity); 435 | case PLATFORM.SQUEEZEBOX: 436 | return this.callService(e, 'unsync', options, PLATFORM.SQUEEZEBOX); 437 | case PLATFORM.MEDIAPLAYER: 438 | case PLATFORM.SONOS: 439 | return this.callService( 440 | e, 441 | 'unjoin', 442 | { 443 | entity_id: entity, 444 | }, 445 | PLATFORM.MEDIAPLAYER, 446 | ); 447 | case PLATFORM.HEOS: 448 | return this.callService( 449 | e, 450 | 'unjoin', 451 | { 452 | entity_id: typeof entity === 'string' ? entity : entity[0], 453 | }, 454 | PLATFORM.MEDIAPLAYER, 455 | ); 456 | default: 457 | return this.callService(e, 'unjoin', options, platform); 458 | } 459 | } 460 | } 461 | 462 | handleSoundtouch(e: Event, service: string, entity: string | string[]): void { 463 | return this.callService( 464 | e, 465 | service, 466 | { 467 | master: this.master, 468 | slaves: entity, 469 | }, 470 | PLATFORM.SOUNDTOUCH, 471 | true, 472 | ); 473 | } 474 | 475 | toggleScript(e: MouseEvent, id: string, data: Record = {}): void { 476 | const [, name] = id.split('.'); 477 | this.callService( 478 | e, 479 | name, 480 | { 481 | ...data, 482 | }, 483 | 'script', 484 | ); 485 | } 486 | 487 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 488 | toggleService(e: MouseEvent, id: string, data: Record = {}): void { 489 | e.stopPropagation(); 490 | const [domain, service] = id.split('.'); 491 | this.hass.callService(domain, service, { 492 | ...data, 493 | }); 494 | } 495 | 496 | // TODO: type available services 497 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 498 | callService(e: Event, service: string, inOptions?: Record, domain = 'media_player', omit = false): void { 499 | e.stopPropagation(); 500 | this.hass.callService(domain, service, { 501 | ...(!omit && { entity_id: this._entityId }), 502 | ...inOptions, 503 | }); 504 | } 505 | } 506 | -------------------------------------------------------------------------------- /src/sharedStyle.ts: -------------------------------------------------------------------------------- 1 | import { css } from 'lit-element'; 2 | 3 | const sharedStyle = css` 4 | .ellipsis { 5 | overflow: hidden; 6 | text-overflow: ellipsis; 7 | white-space: nowrap; 8 | } 9 | .label { 10 | margin: 0 8px; 11 | } 12 | ha-icon { 13 | width: calc(var(--mmp-unit) * 0.6); 14 | height: calc(var(--mmp-unit) * 0.6); 15 | } 16 | ha-icon-button { 17 | width: var(--mmp-unit); 18 | height: var(--mmp-unit); 19 | color: var(--mmp-text-color, var(--primary-text-color)); 20 | transition: color 0.25s; 21 | } 22 | ha-icon-button[color] { 23 | color: var(--mmp-accent-color, var(--accent-color)) !important; 24 | opacity: 1 !important; 25 | } 26 | ha-icon-button[inactive] { 27 | opacity: 0.5; 28 | } 29 | ha-icon-button ha-icon, 30 | mmp-icon-button ha-icon { 31 | display: flex; 32 | } 33 | `; 34 | 35 | export default sharedStyle; 36 | -------------------------------------------------------------------------------- /src/style.ts: -------------------------------------------------------------------------------- 1 | import { css } from 'lit-element'; 2 | 3 | const style = css` 4 | :host { 5 | overflow: visible !important; 6 | display: block; 7 | --mmp-scale: var(--mini-media-player-scale, 1); 8 | --mmp-unit: calc(var(--mmp-scale) * 40px); 9 | --mmp-name-font-weight: var(--mini-media-player-name-font-weight, 400); 10 | --mmp-accent-color: var(--mini-media-player-accent-color, var(--accent-color, #f39c12)); 11 | --mmp-base-color: var(--mini-media-player-base-color, var(--primary-text-color, #000)); 12 | --mmp-overlay-color: var(--mini-media-player-overlay-color, rgba(0, 0, 0, 0.5)); 13 | --mmp-overlay-color-stop: var(--mini-media-player-overlay-color-stop, 25%); 14 | --mmp-overlay-base-color: var(--mini-media-player-overlay-base-color, #fff); 15 | --mmp-overlay-accent-color: var(--mini-media-player-overlay-accent-color, --mmp-accent-color); 16 | --mmp-text-color: var(--mini-media-player-base-color, var(--primary-text-color, #000)); 17 | --mmp-media-cover-info-color: var(--mini-media-player-media-cover-info-color, --mmp-text-color); 18 | --mmp-text-color-inverted: var(--disabled-text-color); 19 | --mmp-active-color: var(--mmp-accent-color); 20 | --mmp-button-color: var(--mini-media-player-button-color, rgba(255, 255, 255, 0.25)); 21 | --mmp-icon-color: var( 22 | --mini-media-player-icon-color, 23 | var(--mini-media-player-base-color, var(--paper-item-icon-color, #44739e)) 24 | ); 25 | --mmp-icon-active-color: var(--paper-item-icon-active-color, --mmp-active-color); 26 | --mmp-info-opacity: 0.75; 27 | --mmp-bg-opacity: var(--mini-media-player-background-opacity, 1); 28 | --mmp-artwork-opacity: var(--mini-media-player-artwork-opacity, 1); 29 | --mmp-progress-height: var(--mini-media-player-progress-height, 6px); 30 | --mmp-border-radius: var(--ha-card-border-radius, 12px); 31 | --mdc-theme-primary: var(--mmp-text-color); 32 | --mdc-theme-on-primary: var(--mmp-text-color); 33 | --paper-checkbox-unchecked-color: var(--mmp-text-color); 34 | --paper-checkbox-label-color: var(--mmp-text-color); 35 | color: var(--mmp-text-color); 36 | } 37 | ha-card.--bg { 38 | --mmp-info-opacity: 0.75; 39 | } 40 | ha-card.--has-artwork[artwork='material'], 41 | ha-card.--has-artwork[artwork*='cover'] { 42 | --mmp-accent-color: var( 43 | --mini-media-player-overlay-accent-color, 44 | var(--mini-media-player-accent-color, var(--accent-color, #f39c12)) 45 | ); 46 | --mmp-text-color: var(--mmp-overlay-base-color); 47 | --mmp-text-color-inverted: #000; 48 | --mmp-active-color: rgba(255, 255, 255, 0.5); 49 | --mmp-icon-color: var(--mmp-text-color); 50 | --mmp-icon-active-color: var(--mmp-text-color); 51 | --mmp-info-opacity: 0.75; 52 | --paper-slider-container-color: var(--mini-media-player-overlay-color, rgba(255, 255, 255, 0.75)) !important; 53 | --mdc-theme-primary: var(--mmp-text-color); 54 | --mdc-theme-on-primary: var(--mmp-text-color); 55 | --paper-checkbox-unchecked-color: var(--mmp-text-color); 56 | --paper-checkbox-label-color: var(--mmp-text-color); 57 | --switch-checked-color: var(--mmp-accent-color); 58 | --switch-checked-button-color: var(--mmp-accent-color); 59 | --switch-checked-track-color: var(--mmp-accent-color); 60 | --switch-unchecked-color: var(--mmp-text-color); 61 | --switch-unchecked-button-color: var(--mmp-text-color); 62 | --switch-unchecked-track-color: var(--mmp-text-color); 63 | --mdc-text-field-fill-color: transparent; 64 | --mdc-text-field-ink-color: var(--mmp-text-color); 65 | --mdc-text-field-idle-line-color: var(--mmp-text-color); 66 | --mdc-text-field-label-ink-color: var(--mmp-text-color); 67 | --mdc-text-field-hover-line-color: var(--mmp-text-color); 68 | --mdc-ripple-color: var(--mmp-text-color); 69 | --text-field-padding: 0; 70 | color: var(--mmp-text-color); 71 | } 72 | ha-card { 73 | cursor: default; 74 | display: flex; 75 | background: transparent; 76 | overflow: visible; 77 | padding: 0; 78 | position: relative; 79 | color: inherit; 80 | font-size: calc(var(--mmp-unit) * 0.35); 81 | --mdc-icon-button-size: calc(var(--mmp-unit)); 82 | --mdc-icon-size: calc(var(--mmp-unit) * 0.6); 83 | } 84 | ha-card.--group { 85 | box-shadow: none; 86 | border: none; 87 | --mmp-progress-height: var(--mini-media-player-progress-height, 4px); 88 | --mmp-border-radius: 0px 89 | } 90 | ha-card.--more-info { 91 | cursor: pointer; 92 | } 93 | .mmp__bg, 94 | .mmp-player, 95 | .mmp__container { 96 | border-radius: var(--mmp-border-radius); 97 | } 98 | .mmp__container { 99 | overflow: hidden; 100 | height: 100%; 101 | width: 100%; 102 | position: absolute; 103 | pointer-events: none; 104 | -webkit-transform: translateZ(0); 105 | transform: translateZ(0); 106 | } 107 | ha-card:before { 108 | content: ''; 109 | padding-top: 0px; 110 | transition: padding-top 0.5s cubic-bezier(0.21, 0.61, 0.35, 1); 111 | will-change: padding-top; 112 | } 113 | ha-card.--initial .entity__artwork, 114 | ha-card.--initial .entity__icon { 115 | animation-duration: 0.001s; 116 | } 117 | ha-card.--initial:before, 118 | ha-card.--initial .mmp-player { 119 | transition: none; 120 | } 121 | header { 122 | display: none; 123 | } 124 | ha-card[artwork='full-cover'].--has-artwork:before { 125 | padding-top: 56%; 126 | } 127 | ha-card[artwork='full-cover'].--has-artwork[content='music']:before, 128 | ha-card[artwork='full-cover-fit'].--has-artwork:before { 129 | padding-top: 100%; 130 | } 131 | .mmp__bg { 132 | background: var(--ha-card-background, var(--card-background-color, var(--paper-card-background-color, white))); 133 | position: absolute; 134 | top: 0; 135 | right: 0; 136 | bottom: 0; 137 | left: 0; 138 | overflow: hidden; 139 | -webkit-transform: translateZ(0); 140 | transform: translateZ(0); 141 | opacity: var(--mmp-bg-opacity); 142 | } 143 | ha-card[artwork='material'].--has-artwork .mmp__bg, 144 | ha-card[artwork*='cover'].--has-artwork .mmp__bg { 145 | opacity: var(--mmp-artwork-opacity); 146 | background: transparent; 147 | } 148 | ha-card[artwork='material'].--has-artwork .cover { 149 | height: 100%; 150 | right: 0; 151 | left: unset; 152 | animation: fade-in 4s cubic-bezier(0.21, 0.61, 0.35, 1) !important; 153 | } 154 | ha-card[artwork='material'].--has-artwork .cover.--prev { 155 | animation: fade-in 1s linear reverse forwards !important; 156 | } 157 | ha-card[artwork='material'].--has-artwork .cover-gradient { 158 | position: absolute; 159 | height: 100%; 160 | right: 0; 161 | left: 0; 162 | opacity: 1; 163 | } 164 | ha-card.--group .mmp__bg { 165 | background: transparent; 166 | } 167 | ha-card.--inactive .cover { 168 | opacity: 0; 169 | } 170 | ha-card.--inactive .cover.--bg { 171 | opacity: 1; 172 | } 173 | .cover-gradient { 174 | transition: opacity 0.45s linear; 175 | opacity: 0; 176 | } 177 | .cover, 178 | .cover:before { 179 | display: block; 180 | opacity: 0; 181 | position: absolute; 182 | top: 0; 183 | right: 0; 184 | bottom: 0; 185 | left: 0; 186 | transition: opacity 0.75s linear, width 0.05s cubic-bezier(0.21, 0.61, 0.35, 1); 187 | will-change: opacity; 188 | } 189 | .cover:before { 190 | content: ''; 191 | background: var(--mmp-overlay-color); 192 | } 193 | .cover { 194 | animation: fade-in 0.5s cubic-bezier(0.21, 0.61, 0.35, 1); 195 | background-size: cover; 196 | background-repeat: no-repeat; 197 | background-position: center center; 198 | border-radius: var(--mmp-border-radius, 0); 199 | overflow: hidden; 200 | } 201 | .cover.--prev { 202 | animation: fade-in 0.5s linear reverse forwards; 203 | } 204 | .cover.--bg { 205 | opacity: 1; 206 | } 207 | ha-card[artwork*='full-cover'].--has-artwork .mmp-player { 208 | background: linear-gradient(to top, var(--mmp-overlay-color) var(--mmp-overlay-color-stop), transparent 100%); 209 | } 210 | ha-card.--has-artwork .cover, 211 | ha-card.--has-artwork[artwork='cover'] .cover:before { 212 | opacity: 0.999; 213 | } 214 | ha-card[artwork='default'] .cover { 215 | display: none; 216 | } 217 | ha-card.--bg .cover { 218 | display: block; 219 | } 220 | ha-card[artwork='material'].--has-artwork .cover { 221 | background-size: cover; 222 | } 223 | ha-card[artwork='full-cover-fit'].--has-artwork .cover { 224 | background-color: black; 225 | background-size: contain; 226 | } 227 | .mmp-player { 228 | align-self: flex-end; 229 | box-sizing: border-box; 230 | position: relative; 231 | padding: 16px; 232 | transition: padding 0.25s ease-out; 233 | width: 100%; 234 | will-change: padding; 235 | } 236 | ha-card.--group .mmp-player { 237 | padding: 2px 0; 238 | } 239 | .flex { 240 | display: flex; 241 | display: -ms-flexbox; 242 | display: -webkit-flex; 243 | flex-direction: row; 244 | } 245 | .mmp-player__core { 246 | position: relative; 247 | } 248 | .entity__info { 249 | justify-content: center; 250 | display: flex; 251 | flex-direction: column; 252 | margin-left: 8px; 253 | position: relative; 254 | overflow: hidden; 255 | user-select: none; 256 | } 257 | ha-card.--rtl .entity__info { 258 | margin-left: auto; 259 | margin-right: calc(var(--mmp-unit) / 5); 260 | } 261 | ha-card[content='movie'] .attr__media_season, 262 | ha-card[content='movie'] .attr__media_episode { 263 | display: none; 264 | } 265 | .entity__icon { 266 | color: var(--mmp-icon-color); 267 | } 268 | .entity__icon[color] { 269 | color: var(--mmp-icon-active-color); 270 | } 271 | .entity__artwork, 272 | .entity__icon { 273 | animation: fade-in 0.25s ease-out; 274 | background-position: center center; 275 | background-repeat: no-repeat; 276 | background-size: cover; 277 | border-radius: 100%; 278 | height: var(--mmp-unit); 279 | width: var(--mmp-unit); 280 | min-width: var(--mmp-unit); 281 | line-height: var(--mmp-unit); 282 | margin-right: calc(var(--mmp-unit) / 5); 283 | position: relative; 284 | text-align: center; 285 | will-change: border-color; 286 | transition: border-color 0.25s ease-out; 287 | } 288 | ha-card.--rtl .entity__artwork, 289 | ha-card.--rtl .entity__icon { 290 | margin-right: auto; 291 | } 292 | .entity__artwork[border] { 293 | border: 2px solid var(--primary-text-color); 294 | box-sizing: border-box; 295 | -moz-box-sizing: border-box; 296 | -webkit-box-sizing: border-box; 297 | } 298 | .entity__artwork[border][state='playing'] { 299 | border-color: var(--mmp-accent-color); 300 | } 301 | .entity__info__name, 302 | .entity__info__media[short] { 303 | overflow: hidden; 304 | text-overflow: ellipsis; 305 | white-space: nowrap; 306 | } 307 | .entity__info__name { 308 | line-height: calc(var(--mmp-unit) / 2); 309 | color: var(--mmp-text-color); 310 | font-weight: var(--mmp-name-font-weight); 311 | } 312 | .entity__info__media { 313 | color: var(--secondary-text-color); 314 | max-height: 6em; 315 | word-break: break-word; 316 | opacity: var(--mmp-info-opacity); 317 | transition: color 0.5s; 318 | } 319 | .entity__info__media[short] { 320 | max-height: calc(var(--mmp-unit) / 2); 321 | overflow: hidden; 322 | } 323 | .attr__app_name { 324 | display: none; 325 | } 326 | .attr__app_name:first-child, 327 | .attr__app_name:first-of-type { 328 | display: inline; 329 | } 330 | .mmp-player__core[inactive] .entity__info__media { 331 | color: var(--mmp-text-color); 332 | max-width: 200px; 333 | opacity: 0.5; 334 | } 335 | .entity__info__media[short-scroll] { 336 | max-height: calc(var(--mmp-unit) / 2); 337 | white-space: nowrap; 338 | } 339 | .entity__info__media[scroll] > span { 340 | visibility: hidden; 341 | } 342 | .entity__info__media[scroll] > div { 343 | animation: move linear infinite; 344 | } 345 | .entity__info__media[scroll] .marquee { 346 | animation: slide linear infinite; 347 | } 348 | .entity__info__media[scroll] .marquee, 349 | .entity__info__media[scroll] > div { 350 | animation-duration: inherit; 351 | visibility: visible; 352 | } 353 | .entity__info__media[scroll] { 354 | animation-duration: 10s; 355 | mask-image: linear-gradient(to right, transparent 0%, black 5%, black 95%, transparent 100%); 356 | -webkit-mask-image: linear-gradient(to right, transparent 0%, black 5%, black 95%, transparent 100%); 357 | } 358 | .marquee { 359 | visibility: hidden; 360 | position: absolute; 361 | white-space: nowrap; 362 | } 363 | ha-card[artwork*='cover'].--has-artwork .entity__info__media, 364 | ha-card.--bg .entity__info__media { 365 | color: var(--mmp-media-cover-info-color); 366 | } 367 | .entity__info__media span:before { 368 | content: ' - '; 369 | } 370 | .entity__info__media span:first-of-type:before { 371 | content: ''; 372 | } 373 | .entity__info__media span:empty { 374 | display: none; 375 | } 376 | .mmp-player__adds { 377 | margin-left: calc(var(--mmp-unit) * 1.2); 378 | position: relative; 379 | } 380 | ha-card.--rtl .mmp-player__adds { 381 | margin-left: auto; 382 | margin-right: calc(var(--mmp-unit) * 1.2); 383 | } 384 | .mmp-player__adds > *:nth-child(2) { 385 | margin-top: 0px; 386 | } 387 | mmp-powerstrip { 388 | flex: 1; 389 | justify-content: flex-end; 390 | margin-right: 0; 391 | margin-left: auto; 392 | width: auto; 393 | max-width: 100%; 394 | } 395 | mmp-media-controls { 396 | flex-wrap: wrap; 397 | } 398 | ha-card.--flow mmp-powerstrip { 399 | justify-content: space-between; 400 | margin-left: auto; 401 | } 402 | ha-card.--flow.--rtl mmp-powerstrip { 403 | margin-right: auto; 404 | } 405 | ha-card.--flow .entity__info { 406 | display: none; 407 | } 408 | ha-card.--responsive .mmp-player__adds { 409 | margin-left: 0; 410 | } 411 | ha-card.--responsive.--rtl .mmp-player__adds { 412 | margin-right: 0; 413 | } 414 | ha-card.--responsive .mmp-player__adds > mmp-media-controls { 415 | padding: 0; 416 | } 417 | ha-card.--progress .mmp-player { 418 | padding-bottom: calc(16px + calc(var(--mini-media-player-progress-height, 6px) - 6px)); 419 | } 420 | ha-card.--progress.--group .mmp-player { 421 | padding-bottom: calc(10px + calc(var(--mini-media-player-progress-height, 6px) - 6px)); 422 | } 423 | ha-card.--runtime .mmp-player { 424 | padding-bottom: calc(16px + 16px + var(--mini-media-player-progress-height, 0px)); 425 | } 426 | ha-card.--runtime.--group .mmp-player { 427 | padding-bottom: calc(16px + 12px + var(--mini-media-player-progress-height, 0px)); 428 | } 429 | ha-card.--inactive .mmp-player { 430 | padding: 16px; 431 | } 432 | ha-card.--inactive.--group .mmp-player { 433 | padding: 2px 0; 434 | } 435 | .mmp-player div:empty { 436 | display: none; 437 | } 438 | @keyframes slide { 439 | 100% { 440 | transform: translateX(-100%); 441 | } 442 | } 443 | @keyframes move { 444 | from { 445 | transform: translateX(100%); 446 | } 447 | to { 448 | transform: translateX(0); 449 | } 450 | } 451 | @keyframes fade-in { 452 | from { 453 | opacity: 0; 454 | } 455 | to { 456 | opacity: 1; 457 | } 458 | } 459 | ha-switch { 460 | padding: 16px 6px; 461 | } 462 | `; 463 | 464 | export default style; 465 | -------------------------------------------------------------------------------- /src/translations.ts: -------------------------------------------------------------------------------- 1 | // Add additional languages with their ISO 639-1 language code 2 | 3 | const translations = { 4 | en: { 5 | placeholder: { 6 | tts: 'Text to speech', 7 | }, 8 | label: { 9 | leave: 'Leave', 10 | ungroup: 'Ungroup', 11 | group_all: 'Group all', 12 | send: 'Send', 13 | master: 'Master', 14 | }, 15 | state: { 16 | idle: 'Idle', 17 | unavailable: 'Unavailable', 18 | }, 19 | title: { 20 | speaker_management: 'Group management', 21 | }, 22 | }, 23 | de: { 24 | placeholder: { 25 | tts: 'Text zum Sprechen', 26 | }, 27 | label: { 28 | leave: 'Verlassen', 29 | ungroup: 'Teilen', 30 | group_all: 'Gruppieren', 31 | send: 'Senden', 32 | master: 'Master', 33 | }, 34 | state: { 35 | idle: 'Pause', 36 | unavailable: 'Nicht verfügbar', 37 | }, 38 | title: { 39 | speaker_management: 'Wiedergabe auf', 40 | }, 41 | }, 42 | fi: { 43 | placeholder: { 44 | tts: 'Teksti puheeksi', 45 | }, 46 | label: { 47 | leave: 'Jätä', 48 | ungroup: 'Pura ryhmä', 49 | group_all: 'Liitä kaikki', 50 | send: 'Lähetä', 51 | master: 'Master', 52 | }, 53 | state: { 54 | idle: 'Tauko', 55 | unavailable: 'Ei käytettävissä', 56 | }, 57 | title: { 58 | speaker_management: 'Ryhmän hallinta', 59 | }, 60 | }, 61 | fr: { 62 | placeholder: { 63 | tts: 'Texte à lire', 64 | }, 65 | label: { 66 | leave: 'Quitter', 67 | ungroup: 'Dégrouper', 68 | group_all: 'Grouper tous', 69 | send: 'Envoyer', 70 | }, 71 | state: { 72 | idle: 'Inactif', 73 | unavailable: 'Indisponible', 74 | }, 75 | title: { 76 | speaker_management: 'Gestion des groupes', 77 | }, 78 | }, 79 | he: { 80 | placeholder: { 81 | tts: 'טקסט לדיבור', 82 | }, 83 | label: { 84 | leave: 'לעזוב', 85 | ungroup: 'ביטול קבוצה', 86 | group_all: 'לקבץ את כולם', 87 | send: 'שליחה', 88 | master: 'ראשי', 89 | }, 90 | state: { 91 | idle: 'לא פעיל', 92 | unavailable: 'לא זמין', 93 | }, 94 | title: { 95 | speaker_management: 'ניהול קבוצות', 96 | }, 97 | }, 98 | hu: { 99 | placeholder: { 100 | tts: 'Szövegfelolvasás', 101 | }, 102 | label: { 103 | leave: 'Kilépés', 104 | ungroup: 'Összes ki', 105 | group_all: 'Összes be', 106 | send: 'Küldés', 107 | master: 'Forrás', 108 | }, 109 | state: { 110 | idle: 'Tétlen', 111 | unavailable: 'Nem elérhető', 112 | }, 113 | title: { 114 | speaker_management: 'Hangszórók csoportosítása', 115 | }, 116 | }, 117 | it: { 118 | placeholder: { 119 | tts: 'Conversione testo in voce', 120 | }, 121 | label: { 122 | leave: 'Lascia', 123 | ungroup: 'Separa', 124 | group_all: 'Raggruppa tutti', 125 | send: 'Invia', 126 | master: 'Master', 127 | }, 128 | state: { 129 | idle: 'Inattivo', 130 | unavailable: 'Non disponibile', 131 | }, 132 | title: { 133 | speaker_management: 'Gestione gruppo', 134 | }, 135 | }, 136 | is: { 137 | placeholder: { 138 | tts: 'Texti sem á að segja', 139 | }, 140 | label: { 141 | leave: 'Yfirgefa', 142 | ungroup: 'Aðskilja', 143 | group_all: 'Sameina alla', 144 | send: 'Senda', 145 | master: 'Stjórnandi', 146 | }, 147 | state: { 148 | idle: 'Aðgerðalaus', 149 | unavailable: 'Ekki tiltækt', 150 | }, 151 | title: { 152 | speaker_management: 'Stjórnun hópa', 153 | }, 154 | }, 155 | no: { 156 | placeholder: { 157 | tts: 'Tekst til tale', 158 | }, 159 | label: { 160 | leave: 'Forlat', 161 | ungroup: 'Oppløs gruppe', 162 | group_all: 'Grupper alle', 163 | send: 'Send', 164 | master: 'Master', 165 | }, 166 | state: { 167 | idle: 'Inaktiv', 168 | unavailable: 'Utilgjengelig', 169 | }, 170 | title: { 171 | speaker_management: 'Gruppestyring', 172 | }, 173 | }, 174 | pl: { 175 | placeholder: { 176 | tts: 'Zamień tekst na mowę', 177 | }, 178 | label: { 179 | leave: 'Opuść', 180 | ungroup: 'Usuń grupę', 181 | group_all: 'Grupuj wszystkie', 182 | send: 'Wyślij', 183 | }, 184 | state: { 185 | idle: 'brak aktywności', 186 | unavailable: 'niedostępny', 187 | }, 188 | title: { 189 | speaker_management: 'Zarządzanie grupą', 190 | }, 191 | }, 192 | sv: { 193 | placeholder: { 194 | tts: 'Text till tal', 195 | }, 196 | label: { 197 | leave: 'Lämna', 198 | ungroup: 'Avgruppera', 199 | group_all: 'Gruppera alla', 200 | send: 'Skicka', 201 | master: 'Master', 202 | }, 203 | state: { 204 | idle: "Inaktiv", 205 | unavailable: 'Otillgänglig', 206 | }, 207 | title: { 208 | speaker_management: 'Gruppstyrning', 209 | } 210 | }, 211 | uk: { 212 | placeholder: { 213 | tts: 'Текст для відтворення', 214 | }, 215 | label: { 216 | leave: 'Залишити', 217 | ungroup: 'Розгрупувати', 218 | group_all: 'Згрупувати всі', 219 | send: 'Надіслати', 220 | master: 'Головний', 221 | }, 222 | state: { 223 | idle: 'бездіяльність', 224 | unavailable: 'недоступний', 225 | }, 226 | title: { 227 | speaker_management: 'Управління групою', 228 | }, 229 | }, 230 | cz: { 231 | placeholder: { 232 | tts: 'Převeď text na řeč', 233 | }, 234 | label: { 235 | leave: 'Odejít', 236 | ungroup: 'Zrušit seskupení', 237 | group_all: 'Seskupit vše', 238 | send: 'Poslat', 239 | master: 'Master', 240 | }, 241 | state: { 242 | idle: 'Nečinný', 243 | unavailable: 'Nedostupný', 244 | }, 245 | title: { 246 | speaker_management: 'Správa skupin', 247 | }, 248 | }, 249 | ru: { 250 | placeholder: { 251 | tts: 'Преобразование текста в речь', 252 | }, 253 | label: { 254 | leave: 'Покинуть', 255 | ungroup: 'Разгруппировать', 256 | group_all: 'Сгруппировать все', 257 | send: 'Отправить', 258 | master: 'Мастер', 259 | }, 260 | state: { 261 | idle: 'Бездействие', 262 | unavailable: 'Недоступен', 263 | }, 264 | title: { 265 | speaker_management: 'Управление группой', 266 | }, 267 | }, 268 | es: { 269 | placeholder: { 270 | tts: 'Texto a voz', 271 | }, 272 | label: { 273 | leave: 'Salir', 274 | ungroup: 'Desagrupar', 275 | group_all: 'Agrupar todos', 276 | send: 'Enviar', 277 | master: 'Maestro', 278 | }, 279 | state: { 280 | idle: 'Inactivo', 281 | unavailable: 'No disponible', 282 | }, 283 | title: { 284 | speaker_management: 'Gestión de grupo', 285 | }, 286 | }, 287 | zh: { 288 | placeholder: { 289 | tts: '播放文本', 290 | }, 291 | label: { 292 | leave: '退出', 293 | ungroup: '取消组合', 294 | group_all: '组合全部', 295 | send: '发送', 296 | master: '主要的', 297 | }, 298 | state: { 299 | idle: '空闲', 300 | unavailable: '不可用', 301 | }, 302 | title: { 303 | speaker_management: '组合管理', 304 | }, 305 | }, 306 | sk: { 307 | placeholder: { 308 | tts: 'Prevod textu na reč', 309 | }, 310 | label: { 311 | leave: 'Odísť', 312 | ungroup: 'Zrušiť zoskupenie', 313 | group_all: 'Zoskupiť všetky', 314 | send: 'Poslať', 315 | master: 'Master', 316 | }, 317 | state: { 318 | idle: 'Nečinný', 319 | unavailable: 'Nedostupné', 320 | }, 321 | title: { 322 | speaker_management: 'Manažment skupiny', 323 | }, 324 | }, 325 | ca: { 326 | placeholder: { 327 | tts: 'Text a veu', 328 | }, 329 | label: { 330 | leave: 'Sortir', 331 | ungroup: 'Desagrupar', 332 | group_all: 'Agrupar-los tots', 333 | send: 'Enviar', 334 | master: 'Mestre', 335 | }, 336 | state: { 337 | idle: 'Inactiu', 338 | unavailable: 'No disponible', 339 | }, 340 | title: { 341 | speaker_management: 'Gestió del grup', 342 | }, 343 | }, 344 | nl: { 345 | placeholder: { 346 | tts: 'Tekst naar spraak', 347 | }, 348 | label: { 349 | leave: 'Verlaten', 350 | ungroup: 'Ontgroeperen', 351 | group_all: 'Alles groeperen', 352 | send: 'Verzenden', 353 | master: 'Master', 354 | }, 355 | state: { 356 | idle: 'Inactief', 357 | unavailable: 'Niet beschikbaar', 358 | }, 359 | title: { 360 | speaker_management: 'Groepsbeheer', 361 | }, 362 | }, 363 | pt: { 364 | placeholder: { 365 | tts: 'Texto para fala', 366 | }, 367 | label: { 368 | leave: 'Sair', 369 | ungroup: 'Desagrupar', 370 | group_all: 'Agrupar tudo', 371 | send: 'Enviar', 372 | master: 'Master', 373 | }, 374 | state: { 375 | idle: 'Ocioso', 376 | unavailable: 'Indisponível', 377 | }, 378 | title: { 379 | speaker_management: 'Gerenciamento de grupo', 380 | }, 381 | }, 382 | cs: { 383 | placeholder: { 384 | tts: 'Převod textu na řeč', 385 | }, 386 | label: { 387 | leave: 'Opustit', 388 | ungroup: 'Zrušit seskupení', 389 | group_all: 'Seskupit vše', 390 | send: 'Poslat', 391 | master: 'Master', 392 | }, 393 | state: { 394 | idle: 'Nečinný', 395 | unavailable: 'Nedostupné', 396 | }, 397 | title: { 398 | speaker_management: 'Správa skupiny', 399 | }, 400 | }, 401 | }; 402 | 403 | export default translations; 404 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Connection, 3 | Context, 4 | HassConfig, 5 | HassEntities, 6 | HassEntityAttributeBase, 7 | HassEntityBase, 8 | HassServices, 9 | HassServiceTarget, 10 | MessageBase, 11 | } from 'home-assistant-js-websocket'; 12 | 13 | export interface HomeAssistant { 14 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 15 | hassUrl(picture: any); 16 | connection: Connection; 17 | connected: boolean; 18 | states: HassEntities; 19 | services: HassServices; 20 | config: HassConfig; 21 | panelUrl: string; 22 | // i18n 23 | // current effective language in that order: 24 | // - backend saved user selected language 25 | // - language in local app storage 26 | // - browser language 27 | // - english (en) 28 | language: string; 29 | // local stored language, keep that name for backward compatibility 30 | selectedLanguage: string | null; 31 | translationMetadata: TranslationMetadata; 32 | vibrate: boolean; 33 | resources: Resources; 34 | callService( 35 | domain: ServiceCallRequest['domain'], 36 | service: ServiceCallRequest['service'], 37 | serviceData?: ServiceCallRequest['serviceData'], 38 | target?: ServiceCallRequest['target'], 39 | ): Promise; 40 | callApi( 41 | method: 'GET' | 'POST' | 'PUT' | 'DELETE', 42 | path: string, 43 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 44 | parameters?: Record, 45 | headers?: Record, 46 | ): Promise; 47 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 48 | fetchWithAuth(path: string, init?: Record): Promise; 49 | sendWS(msg: MessageBase): void; 50 | callWS(msg: MessageBase): Promise; 51 | } 52 | 53 | export interface Translation { 54 | nativeName: string; 55 | isRTL: boolean; 56 | hash: string; 57 | } 58 | 59 | export interface TranslationMetadata { 60 | fragments: string[]; 61 | translations: { 62 | [lang: string]: Translation; 63 | }; 64 | } 65 | 66 | export interface ServiceCallRequest { 67 | domain: string; 68 | service: string; 69 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 70 | serviceData?: Record; 71 | target?: HassServiceTarget; 72 | } 73 | 74 | export interface ServiceCallResponse { 75 | context: Context; 76 | } 77 | 78 | export interface Resources { 79 | [language: string]: Record; 80 | } 81 | 82 | export enum MediaPlayerEntityState { 83 | PLAYING = 'playing', 84 | PAUSED = 'paused', 85 | IDLE = 'idle', 86 | OFF = 'off', 87 | ON = 'on', 88 | UNAVAILABLE = 'unavailable', 89 | UNKNOWN = 'unknown', 90 | STANDBY = 'standby', 91 | } 92 | 93 | export interface MediaPlayerEntity extends HassEntityBase { 94 | attributes: MediaPlayerEntityAttributes; 95 | state: MediaPlayerEntityState; 96 | } 97 | 98 | export interface MediaPlayerEntityAttributes extends HassEntityAttributeBase { 99 | media_content_id?: string; 100 | media_content_type?: string; 101 | media_artist?: string; 102 | media_playlist?: string; 103 | media_series_title?: string; 104 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 105 | media_season?: any; 106 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 107 | media_episode?: any; 108 | app_name?: string; 109 | media_position_updated_at?: string | number | Date; 110 | media_duration?: number; 111 | media_position?: number; 112 | media_title?: string; 113 | icon?: string; 114 | entity_picture_local?: string; 115 | is_volume_muted?: boolean; 116 | volume_level?: number; 117 | source?: string; 118 | source_list?: string[]; 119 | sound_mode?: string; 120 | sound_mode_list?: string[]; 121 | // TODO: type this; 122 | repeat?: string; 123 | shuffle?: boolean; 124 | group_members?: string[]; 125 | sync_group?: string[]; 126 | } 127 | -------------------------------------------------------------------------------- /src/utils/colorGenerator.js: -------------------------------------------------------------------------------- 1 | import Vibrant from 'node-vibrant/dist/vibrant'; 2 | 3 | import { CONTRAST_RATIO, COLOR_SIMILARITY_THRESHOLD } from '../const'; 4 | 5 | const luminance = (r, g, b) => { 6 | const a = [r, g, b].map((v) => { 7 | let w = v; 8 | w /= 255; 9 | return w <= 0.03928 ? w / 12.92 : ((w + 0.055) / 1.055) ** 2.4; 10 | }); 11 | return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722; 12 | }; 13 | 14 | const contrast = (rgb1, rgb2) => { 15 | const lum1 = luminance(...rgb1); 16 | const lum2 = luminance(...rgb2); 17 | const brightest = Math.max(lum1, lum2); 18 | const darkest = Math.min(lum1, lum2); 19 | return (brightest + 0.05) / (darkest + 0.05); 20 | }; 21 | 22 | const getContrastRatio = (rgb1, rgb2) => Math.round((contrast(rgb1, rgb2) + Number.EPSILON) * 100) / 100; 23 | 24 | const colorGenerator = (colors) => { 25 | colors.sort((colorA, colorB) => colorB.population - colorA.population); 26 | 27 | const backgroundColor = colors[0]; 28 | let foregroundColor; 29 | 30 | const contrastRatios = new Map(); 31 | const approvedContrastRatio = (hex, rgb) => { 32 | if (!contrastRatios.has(hex)) { 33 | contrastRatios.set(hex, getContrastRatio(backgroundColor.rgb, rgb)); 34 | } 35 | 36 | return contrastRatios.get(hex) > CONTRAST_RATIO; 37 | }; 38 | 39 | // We take each next color and find one that has better contrast. 40 | for (let i = 1; i < colors.length && foregroundColor === undefined; i++) { 41 | // If this color matches, score, take it. 42 | if (approvedContrastRatio(colors[i].hex, colors[i].rgb)) { 43 | foregroundColor = colors[i].rgb; 44 | break; 45 | } 46 | 47 | // This color has the wrong contrast ratio, but it is the right color. 48 | // Let's find similar colors that might have the right contrast ratio 49 | 50 | const currentColor = colors[i]; 51 | 52 | for (let j = i + 1; j < colors.length; j++) { 53 | const compareColor = colors[j]; 54 | 55 | // difference. 0 is same, 765 max difference 56 | const diffScore = 57 | Math.abs(currentColor.rgb[0] - compareColor.rgb[0]) + 58 | Math.abs(currentColor.rgb[1] - compareColor.rgb[1]) + 59 | Math.abs(currentColor.rgb[2] - compareColor.rgb[2]); 60 | 61 | if (diffScore > COLOR_SIMILARITY_THRESHOLD) { 62 | continue; 63 | } 64 | 65 | if (approvedContrastRatio(compareColor.hex, compareColor.rgb)) { 66 | foregroundColor = compareColor.rgb; 67 | break; 68 | } 69 | } 70 | } 71 | 72 | if (foregroundColor === undefined) { 73 | foregroundColor = backgroundColor.getYiq() < 200 ? [255, 255, 255] : [0, 0, 0]; 74 | } 75 | 76 | return [new backgroundColor.constructor(foregroundColor, 0).hex, backgroundColor.hex]; 77 | }; 78 | 79 | Vibrant._pipeline.generator.register('default', colorGenerator); 80 | export default async (picture) => new Vibrant(picture, { colorCount: 16 }).getPalette(); 81 | -------------------------------------------------------------------------------- /src/utils/getProgress.ts: -------------------------------------------------------------------------------- 1 | export default (duration: number): string => { 2 | let seconds: number | string = Math.abs(parseInt(`${duration % 60}`, 10)); 3 | let minutes: number | string = Math.abs(parseInt(`${(duration / 60) % 60}`, 10)); 4 | let hours: number | string = Math.abs(parseInt(`${(duration / (60 * 60)) % 24}`, 10)); 5 | 6 | hours = hours < 10 ? `0${hours}` : hours; 7 | minutes = minutes < 10 ? `0${minutes}` : minutes; 8 | seconds = seconds < 10 ? `0${seconds}` : seconds; 9 | 10 | return `${hours !== '00' ? `${hours}:` : ''}${minutes}:${seconds}`; 11 | }; 12 | -------------------------------------------------------------------------------- /src/utils/handleClick.js: -------------------------------------------------------------------------------- 1 | const forwardHaptic = (node, haptic) => { 2 | const e = new Event('haptic', { composed: true }); 3 | e.detail = { haptic }; 4 | node.dispatchEvent(e); 5 | }; 6 | 7 | export default (node, hass, config, actionConfig, entityId) => { 8 | let e; 9 | // eslint-disable-next-line default-case 10 | switch (actionConfig.action) { 11 | case 'more-info': { 12 | e = new Event('hass-more-info', { composed: true }); 13 | e.detail = { 14 | entityId: actionConfig.entity || entityId, 15 | }; 16 | node.dispatchEvent(e); 17 | break; 18 | } 19 | case 'navigate': { 20 | if (!actionConfig.navigation_path) return; 21 | window.history.pushState(null, '', actionConfig.navigation_path); 22 | e = new Event('location-changed', { composed: true }); 23 | e.detail = { replace: false }; 24 | window.dispatchEvent(e); 25 | break; 26 | } 27 | case 'call-service': { 28 | if (!actionConfig.service) return; 29 | const [domain, service] = actionConfig.service.split('.', 2); 30 | const serviceData = { ...actionConfig.service_data }; 31 | hass.callService(domain, service, serviceData); 32 | break; 33 | } 34 | case 'url': { 35 | if (!actionConfig.url) return; 36 | if (actionConfig.new_tab) { 37 | window.open(actionConfig.url, '_blank'); 38 | } else { 39 | window.location.href = actionConfig.url; 40 | } 41 | break; 42 | } 43 | case 'fire-dom-event': { 44 | e = new Event('ll-custom', { composed: true, bubbles: true }); 45 | e.detail = actionConfig; 46 | node.dispatchEvent(e); 47 | break; 48 | } 49 | } 50 | 51 | if (actionConfig.haptic) forwardHaptic(node, actionConfig.haptic); 52 | }; 53 | -------------------------------------------------------------------------------- /src/utils/misc.ts: -------------------------------------------------------------------------------- 1 | const arrayBufferToBase64 = (buffer: ArrayBuffer): string => { 2 | let binary = ''; 3 | const bytes = [].slice.call(new Uint8Array(buffer)); 4 | 5 | bytes.forEach((b) => (binary += String.fromCharCode(b))); 6 | 7 | return window.btoa(binary); 8 | }; 9 | 10 | export default arrayBufferToBase64; 11 | -------------------------------------------------------------------------------- /src/utils/translation.ts: -------------------------------------------------------------------------------- 1 | import translations from '../translations'; 2 | import { HomeAssistant } from '../types'; 3 | 4 | const DEFAULT_LANG = 'en'; 5 | 6 | const getNestedProp = (obj, path) => path.split('.').reduce((p, c) => (p && p[c]) || null, obj); 7 | 8 | const translation = (hass: HomeAssistant, label: string, hassLabel?: string, fallback = 'unknown'): string => { 9 | const lang = hass.selectedLanguage || hass.language; 10 | const l639 = lang.split('-')[0]; 11 | return ( 12 | (translations[lang] && getNestedProp(translations[lang], label)) || 13 | (hass.resources[lang] && hassLabel && hass.resources[lang][hassLabel]) || 14 | (translations[l639] && getNestedProp(translations[l639], label)) || 15 | getNestedProp(translations[DEFAULT_LANG], label) || 16 | fallback 17 | ); 18 | }; 19 | 20 | export default translation; 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "lib": ["es2017", "dom", "dom.iterable"], 7 | "noEmit": true, 8 | "noUnusedParameters": true, 9 | "noImplicitReturns": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "strict": true, 12 | "noImplicitAny": false, 13 | "skipLibCheck": true, 14 | "resolveJsonModule": true, 15 | "experimentalDecorators": true 16 | } 17 | } 18 | --------------------------------------------------------------------------------