Custom widget: media player with source select

Hi,

I’m having some trouble with my custom widget.
I’m building this for Sonos but it might work as well with other players.
The widget works fine except for one strange bug:
Sonos starts playing when you load the dashboard. (SOLVED)
I could really use some help with this javascript.

custom_widgets/sonos.yaml
widget_type: basesonos
entity: {{entity}}
post_service_next:
    service: media_player/media_next_track
    entity_id: {{entity}}
post_service_previous:
    service: media_player/media_previous_track
    entity_id: {{entity}}
post_service_play_pause:
    service: media_player/media_play_pause
    entity_id: {{entity}}
post_service_pause:
    service: media_player/media_pause
    entity_id: {{entity}}
post_service_stop:
    service: media_player/media_stop
    entity_id: {{entity}}
post_service_level:
    service: media_player/volume_set
    entity_id: {{entity}}
post_service:
    service: media_player/select_source
    entity_id: {{entity}}
fields:
  title: {{title}}
  artist: ""
  media_title: ""
  album: ""
  play_icon_style: ""
  pause_icon_style: ""
  previous_icon_style: ""
  next_icon_style: ""
  state_text: ""
  level: ""
  inputoptions: []
  selectedoption: ""
icons:
  play_icon: $media_player_icon_play
  pause_icon: $media_player_icon_pause
static_icons:
  previous_icon: $media_player_icon_previous
  next_icon: $media_player_icon_next
  icon_up: $media_player_icon_up
  icon_down: $media_player_icon_down
static_css:
  previous_icon_style: $media_player_icon_style_previous
  next_icon_style: $media_player_icon_style_next
  title_style: $media_player_title_style
  artist_style: $media_player_artist_style
  album_style: $media_player_album_style
  media_title_style: $media_player_media_title_style
  state_text_style: $media_player_state_text_style
  level_style: $media_player_level_style
  level_up_style: $media_player_level_up_style
  level_down_style: $media_player_level_down_style
  widget_style: $media_player_widget_style
  units_style: $media_player_units_style
  select_style: $media_select_select_style
  selectcontainer_style: $media_select_container_style
css:
  icon_style_active: $media_player_icon_style_active
  icon_style_inactive: $media_player_icon_style_inactive
custom_widgets/basesonos/basesonos.yaml
widget_type: basesonos
entity: {{entity}}
post_service_next:
    service: media_player/media_next_track
    entity_id: {{entity}}
post_service_previous:
    service: media_player/media_previous_track
    entity_id: {{entity}}
post_service_play_pause:
    service: media_player/media_play_pause
    entity_id: {{entity}}
post_service_pause:
    service: media_player/media_pause
    entity_id: {{entity}}
post_service_stop:
    service: media_player/media_stop
    entity_id: {{entity}}
post_service_level:
    service: media_player/volume_set
    entity_id: {{entity}}
post_service:
    service: media_player/select_source
    entity_id: {{entity}}
fields:
  title: {{title}}
  artist: ""
  media_title: ""
  album: ""
  play_icon_style: ""
  pause_icon_style: ""
  previous_icon_style: ""
  next_icon_style: ""
  state_text: ""
  level: ""
  inputoptions: []
  selectedoption: ""
icons:
  play_icon: $media_player_icon_play
  pause_icon: $media_player_icon_pause
static_icons:
  previous_icon: $media_player_icon_previous
  next_icon: $media_player_icon_next
  icon_up: $media_player_icon_up
  icon_down: $media_player_icon_down
static_css:
  previous_icon_style: $media_player_icon_style_previous
  next_icon_style: $media_player_icon_style_next
  title_style: $media_player_title_style
  artist_style: $media_player_artist_style
  album_style: $media_player_album_style
  media_title_style: $media_player_media_title_style
  state_text_style: $media_player_state_text_style
  level_style: $media_player_level_style
  level_up_style: $media_player_level_up_style
  level_down_style: $media_player_level_down_style
  widget_style: $media_player_widget_style
  units_style: $media_player_units_style
  select_style: $media_select_select_style
  selectcontainer_style: $media_select_container_style
css:
  icon_style_active: $media_player_icon_style_active
  icon_style_inactive: $media_player_icon_style_inactive
custom_widgets/basesonos/basesonos.html
<h1 class="title" data-bind="text: title, attr:{style: title_style}"></h1>
<div class="summary">
    <div class="valign">
        <p class="artist" data-bind="text: artist, attr:{style: artist_style}" />
        <p class="media_title" data-bind="text: media_title, attr:{style: media_title_style}"/>
        <p class="album" data-bind="text: album, attr:{style: album_style}"/>
    </div>
</div>
<h2 id="previous" class="previous" data-bind="attr:{style: previous_icon_style}"><i data-bind="attr: {class: previous_icon}"></i></h2>
<h2 id="play" class="play" data-bind="attr:{style: play_icon_style}"><i data-bind="attr: {class: play_icon}"></i></h2>
<h2 id="next" class="next" data-bind="attr:{style: next_icon_style}"><i data-bind="attr: {class: next_icon}"></i></h2>
<p class="state_text" data-bind="text: state_text, attr:{style: state_text_style}"></p>
<div class="levelunit">
    <p class="level" data-bind="text: level, attr:{style: level_style}"></p>
    <p class="unit" data-bind="attr:{style: units_style}">%</p>
</div>
<p class="secondary-icon minus"><i data-bind="attr: {class: icon_down, style: level_down_style}" id="level-down"></i></p>
<p class="secondary-icon plus"><i data-bind="attr: {class: icon_up, style: level_up_style}" id="level-up"></i></p>
<div class="styled-select" data-bind="attr:{style: selectcontainer_style}">
    <select data-bind="options: inputoptions, value: selectedoption, attr:{style: select_style}"></select>
</div>
custom_widgets/basesonos/basesonos.css
.widget-basesonos-{{id}} {
	position: relative;
}

.widget-basesonos-{{id}} .title {
	position: absolute;
	top: 5px;
	width: 100%;
	color: gray;
}

.widget-basesonos-{{id}} .summary {
	margin-top: -100px;
	width: 100%;
	height: 90px;
	text-align: center;
	display: table;
	table-layout: fixed;
	overflow: hidden;
}

.widget-basesonos-{{id}} .valign {
	display: table-cell;
	vertical-align: middle;
	width: 100%;
}

.widget-basesonos-{{id}} .artist {
	color: gray;
	font-size: 90%;
	font-weight: bold;
	margin: 5px;
}

.widget-basesonos-{{id}} .media_title {
	font-size: 90%;
	font-weight: bold;
	color: gray;
	margin: 5px;
}

.widget-basesonos-{{id}} .album {
	font-size: 80%;
	color: gray;
	margin: 5px;
}

.widget-basesonos-{{id}} .state_text {
	position: absolute;
	top: 28px;
	width: 100%;
}

.widget-basesonos-{{id}} .previous {
    position: absolute;
    top: 110px;
    left: 25px;
    z-index: 10;
}

.widget-basesonos-{{id}} .play {
    position: absolute;
    top: 110px;
    width: 100%;
}

.widget-basesonos-{{id}} .next {
    position: absolute;
    top: 110px;
    right: 25px;
    z-index: 10;
}

.widget-basesonos-{{id}} .level {
	display: inline-block;
}

.widget-basesonos-{{id}} .unit {
	display: inline-block;
}

.widget-basesonos-{{id}} .levelunit {
	position: absolute;
	top: 173px;
	width: 100%;
}

.widget-basesonos-{{id}} .secondary-icon {
	position: absolute;
	bottom: 0px;
	font-size: 20px;
	width: 32px;
	color: white;
}

.widget-basesonos-{{id}} .secondary-icon.plus {
	right: 40px;
	top: 165px;
}

.widget-basesonos-{{id}} .secondary-icon.plus i {
	padding-top: 10px;
	padding-left: 10px;
	font-size: 150%;
}

.widget-basesonos-{{id}} .secondary-icon.minus {
	left: 33px;
	top: 165px;	
}

.widget-basesonos-{{id}} .secondary-icon.minus i {
	padding-top: 10px;
	padding-right: 30px;
	font-size: 150%;
}

.widget-basesonos-{{id}} .styled-select {
   position: absolute;
   top: 205px;
   height: 29px;
   overflow: hidden;
   left: 27px;
   width: 188px;
   horizontal-align: center;
   margin:auto;
}

.widget-basesonos-{{id}} .styled-select>select {
   background-color: white;
   border: gray solid 3px;
   outline: none;
   font-size: 12pt;
   height: 29px;
   padding: 1px;
   width: 100%;
   color: gray;
   -webkit-border-radius: 14px;
   -moz-border-radius: 14px;
   border-radius: 14px;
}
custom_widgets/basesonos/basesonos.js
function basesonos(widget_id, url, skin, parameters)
{
    self = this;

    initial_load = true;
	source_list = ["[Favorites]"]; // First value in the array

    // Initialization

    self.widget_id = widget_id;

    // Parameters may come in useful later on

    self.parameters = parameters;

    self.onClick = onClick;
    self.OnPlayButtonClick = OnPlayButtonClick;
    self.OnPreviousButtonClick = OnPreviousButtonClick;
    self.OnNextButtonClick = OnNextButtonClick;
    self.OnRaiseLevelClick = OnRaiseLevelClick;
    self.OnLowerLevelClick = OnLowerLevelClick;

    self.min_level = 0;
    self.max_level = 1;

    if ("step" in self.parameters)
    {
        self.step = self.parameters.step / 100;
    }
    else
    {
        self.step = 0.1;
    }

    var callbacks =
        [
            {"selector": '#' + widget_id + ' #play', "action": "click", "callback": self.OnPlayButtonClick},
            {"selector": '#' + widget_id + ' #level-up', "action": "click", "callback": self.OnRaiseLevelClick},
            {"selector": '#' + widget_id + ' #level-down', "action": "click", "callback": self.OnLowerLevelClick},
            {"selector": '#' + widget_id + ' #previous', "action": "click", "callback": self.OnPreviousButtonClick},
            {"selector": '#' + widget_id + ' #next', "action": "click", "callback": self.OnNextButtonClick},
            {"selector": '#' + widget_id + ' > div > select', "action": "change", "callback": self.onClick}
        ];

    // Define callbacks for entities - this model allows a widget to monitor multiple entities if needed
    // Initial will be called when the dashboard loads and state has been gathered for the entity
    // Update will be called every time an update occurs for that entity

    self.OnStateAvailable = OnStateAvailable;
    self.OnStateUpdate = OnStateUpdate;

    var monitored_entities =
        [
            {"entity": parameters.entity, "initial": self.OnStateAvailable, "update": self.OnStateUpdate}
        ];

    // Finally, call the parent constructor to get things moving

    WidgetBase.call(self, widget_id, url, skin, parameters, monitored_entities, callbacks);

    // Function Definitions

    // The StateAvailable function will be called when
    // self.state[<entity>] has valid information for the requested entity
    // state is the initial state

    function OnStateAvailable(self, state)
    {
        self.state = state.state;
		source_list = source_list.concat(state.attributes.source_list);
        set_options(self, source_list, state);
//        set_value(self, state);

        self.entity = state.entity_id;
        self.level = state.attributes.volume_level;
        set_view(self, state)
        if ("dump_capabilities" in self.parameters && self.parameters["dump_capabilities"] == "1")
        {
            display_supported_functions(self)
        }
    }

    // The OnStateUpdate function will be called when the specific entity
    // receives a state update - its new values will be available
    // in self.state[<entity>] and returned in the state parameter

    function OnStateUpdate(self, state)
    {
        self.level = state.attributes.volume_level;
        set_view(self, state);
    }

    function OnPlayButtonClick(self)
    {
        if (self.entity_state[self.entity].state !== "playing")
        {
            if (is_supported(self, "PLAY_MEDIA"))
            {
                args = self.parameters.post_service_play_pause;
                self.call_service(self, args);
            }
            else
            {
                console.log("Play attribute not supported")
            }
        }
        else
        {
            if (is_supported(self, "PAUSE"))
            {
                args = self.parameters.post_service_pause;
                self.call_service(self, args)
            }
            else if (is_supported(self, "STOP"))
            {
                args = self.parameters.post_service_stop;
                self.call_service(self, args)
            }
            else if (is_supported(self, "STOP"))
            {
                args = self.parameters.post_service_stop;
                self.call_service(self, args)
            }
            else
            {
                // Try Play/Pause
                args = self.parameters.post_service_play_pause;
                self.call_service(self, args)
            }
        }
    }

    function OnPreviousButtonClick(self)
    {
        if (is_supported(self, "PREVIOUS_TRACK"))
        {
            args = self.parameters.post_service_previous;
            self.call_service(self, args)
        }
        else
        {
            console.log("NEXT_TRACK attribute not supported")
        }
    }

    function OnNextButtonClick(self)
    {
        if (is_supported(self, "NEXT_TRACK"))
        {
            args = self.parameters.post_service_next;
            self.call_service(self, args)
        }
        else
        {
            console.log("NEXT_TRACK attribute not supported")
        }
    }

    function OnRaiseLevelClick(self)
    {
        self.level = Math.round((self.level + self.step) * 100) / 100;
        if (self.level > self.max_level)
        {
            self.level = self.max_level
        }

        args = self.parameters.post_service_level;
        args["volume_level"] = self.level;
        self.call_service(self, args)
    }

    function OnLowerLevelClick(self)
    {
        self.level = Math.round((self.level - self.step) * 100) / 100;
        if (self.level < self.min_level)
        {
            self.level = self.min_level
        }

        args = self.parameters.post_service_level;
        args["volume_level"] = self.level;
        self.call_service(self, args)
    }

    function set_view(self, state)
    {
        self.set_field(self, "artist", " ");
        self.set_field(self, "media_title", " ");
        self.set_field(self, "album", " ");

        if (state.state === "playing")
        {
            self.set_field(self, "play_icon_style", self.css.icon_style_active)
            self.set_icon(self, "play_icon", self.icons.pause_icon)
        }
        else
        {
            self.set_field(self, "play_icon_style", self.css.icon_style_inactive)
            self.set_icon(self, "play_icon", self.icons.play_icon)
        }

        if ("media_artist" in state.attributes)
        {
            self.set_field(self, "artist", state.attributes.media_artist);
        }

        if ("media_album_name" in state.attributes)
        {
            self.set_field(self, "album", state.attributes.media_album_name)
        }
        else
        {
            self.set_field(self, "album", " ");
        }

        if ("media_title" in state.attributes)
        {
            if ("truncate_name" in self.parameters)
            {
                name = state.attributes.media_title.substring(0, self.parameters.truncate_name);
            }
            else
            {
                name = state.attributes.media_title
            }
            self.set_field(self, "media_title", name);
        }
        if ("volume_level" in state.attributes)
        {
            self.set_field(self, "level", Math.round(state.attributes.volume_level * 100))
        }
        else
        {
            self.set_field(self, "level", 0)
        }
    }

    function is_supported(self, attr)
    {
        var support =
            {
                "PAUSE": 1,
                "SEEK": 2,
                "VOLUME_SET": 4,
                "VOLUME_MUTE": 8,
                "PREVIOUS_TRACK": 16,
                "NEXT_TRACK": 32,
                "TURN_ON": 128,
                "TURN_OFF": 256,
                "PLAY_MEDIA": 512,
                "VOLUME_STEP": 1024,
                "SELECT_SOURCE": 2048,
                "STOP": 4096,
                "CLEAR_PLAYLIST": 8192,
                "PLAY": 16384,
                "SHUFFLE_SET": 32768
            };

        var supported = self.entity_state[parameters.entity].attributes.supported_features;

        if (attr in support)
        {
            var attr_value = support[attr];
            if ((supported & attr_value) == attr_value)
            {
                return true
            }
            else
            {
                return false
            }
        }
        else
        {
            console.log("Unknown media player attribute: " + attr)
            return false
        }
    }

    function display_supported_functions(self)
    {
        console.log(self.parameters.entity);
        console.log("Supported Features: " + self.entity_state[parameters.entity].attributes.supported_features);
        console.log("PAUSE: " + is_supported(self, "PAUSE"))
        console.log("SEEK: " + is_supported(self, "SEEK"))
        console.log("VOLUME_SET: " + is_supported(self, "VOLUME_SET"))
        console.log("VOLUME_MUTE: " + is_supported(self, "VOLUME_MUTE"))
        console.log("PREVIOUS_TRACK: " + is_supported(self, "PREVIOUS_TRACK"))
        console.log("NEXT_TRACK: " + is_supported(self, "NEXT_TRACK"))
        console.log("TURN_ON: " + is_supported(self, "TURN_ON"))
        console.log("TURN_OFF: " + is_supported(self, "TURN_OFF"))
        console.log("PLAY_MEDIA: " + is_supported(self, "PLAY_MEDIA"))
        console.log("VOLUME_STEP: " + is_supported(self, "VOLUME_STEP"))
        console.log("SELECT_SOURCE: " + is_supported(self, "SELECT_SOURCE"))
        console.log("STOP: " + is_supported(self, "STOP"))
        console.log("CLEAR_PLAYLIST: " + is_supported(self, "CLEAR_PLAYLIST"))
        console.log("PLAY: " + is_supported(self, "PLAY"))
        console.log("SHUFFLE_SET: " + is_supported(self, "SHUFFLE_SET"))
    }

    function onClick(self, state)
    {
        // Ignore page load, only trigger on user clicks
        if (initial_load == false)
        {
            setTimeout(function()
            {
                if (self.state != self.ViewModel.selectedoption())
                {
                    self.source = self.ViewModel.selectedoption()
                        args = self.parameters.post_service
                    args["source"] = self.source
                        self.call_service(self, args);
                }
            },500)
        }
        initial_load = false;
    }

    function set_options(self, options, state)
    {
        self.set_field(self, "inputoptions", options);
    }
}
sonos_livingroom:
    widget_type: sonos
    entity: media_player.livingroom
    title: "Sonos livingroom"
    step: 5
    #truncate_name: 28
    icon_style_active: "color: #CADA2A;"
    icon_style_inactive: "color: gray;"
    icon_down: mdi-volume-medium
    icon_up: mdi-volume-high
    pause_icon: mdi-pause-circle-outline
    play_icon: mdi-play-circle-outline
    next_icon: mdi-skip-next-circle-outline
    previous_icon: mdi-skip-previous-circle-outline
    level_up_style: "color: gray;"
    level_down_style: "color: gray;"
    level_style: "color: gray;"
    units_style: "color: gray;"
    widget_style: "background: white;"

Chrome (doesn’t show the rounded corners for the dropdown)
Naamloos

Firefox
Naamloos

1 Like

what is the problem, except that chrome and firefox display things different?
because thats what is quite normal behaviour. every browser has its own advantages and disadvantages, and if you want the same to happen at all of them, you need to add twice as much of coding.

The problem is:

sorry missed that (it was late :wink: )
i suspect that your problem is somewhere in the onChange.
when the widget is setup it has state None and then it gets the state and sets it.
in some cases we also did see race problems thats why there is a timeout on the place that you copied.
place some alerts (or console logging) in that function to see what states you got and why and when it starts playing.
can be that you need some extra value checking.

I found a workaround for the issue and updated the code above.
The first item in the dropdown is now called “Favorites”. This will be selected once the dashboard has been loaded. The onChange is now an onClick because the Sonos started automatically on page load.
I also fixed some css errors. The title/artist/album are now centered and will not overflow the widget. No need to truncate the titles. There is room for improvements but it works for me.

2 Likes

I really like your widget and if you have already improvement, please let me know… i am using it since i installed hadashboard.
Great work!