diff --git a/README.md b/README.md index be7efa2..c0d14c2 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,10 @@ To prepare the player to play TTS and save the current state of it for restoring - service: linkplay.snapshot data: entity_id: media_player.sound_room1 + switchinput: true ``` +Note the `switchinput` parameter: if the currently playing source is Spotify and this parameter is `True`, it will only save the current volume of the player. You can use Home Assistant's Spotify integration to pause playback within an automation (read further below). If it's `False`, it will save the current Spotify playlist to the player's preset memory. With other playback sources (like Line-In), it will only switch to network playback. + To restore the player state: ```yaml - service: linkplay.restore @@ -161,7 +164,7 @@ You can specify multiple entity ids separated by comma or use `all` to run the s - Input source - Webradio stream (as long as it's configured as an input source) - USB audio files playback (track will restart from the beginning) -- Spotify (snapshot will use the device's highest preset number to store and recall the current playlist, playback may restart the same track or not, depends on Spotify settings. Note: You have to first manually store at least a preset first with the app). +- Spotify: If the snapshot was taken with `switchinput` as `False`, it will recall the playlist, but playback may restart the same track or not, depends on Spotify settings. With `switchinput` as `True` it will do nothing, but you can resume playback from the Spotify integration in an automation (see example below). If you experience TTS audio being cut off at the beginning, this is because the player needs some time to switch to playing out the stream. The only good solution for this is to add a configurable amount of silence at the beginning of the audio stream, I've modified [Google Translate](https://github.com/nagyrobi/home-assistant-custom-components-google_translate) and [VoiceRSS](https://github.com/nagyrobi/home-assistant-custom-components-voicerss) to do this, they can be installed manually as custom components ([even through HACS, manually](https://hacs.xyz/docs/faq/custom_repositories)). @@ -198,9 +201,30 @@ Implemented commands: If parameter `notify: False` is omitted, results will appear in Lovelace UI's left pane as persistent notifications which can be dismissed. You can specify multiple entity ids separated by comma or use `all` to run the service against. +## Service call examples + +Play a sound file located on an http server or a webradio stream: +```yaml + - service: media_player.play_media + data: + entity_id: media_player.sound_room1 + media_content_id: 'http://icecast.streamserver.tld/mountpoint.mp3' + media_content_type: url +``` + +Play the first sound file located on the local storage directly attached to the device (folder\files order seen by the chip seems to be alphabetic): +```yaml + - service: media_player.play_media + data: + entity_id: media_player.sound_room1 + media_content_id: '1' + media_content_type: music +``` + ## Automation examples -Intrerupt playback of a source, incrase volume by 15%, say a TTS message and resume playback when TTS finishes: +Intrerupt playback of a source, incrase volume by 15%, say a TTS message and resume playback when TTS finishes. Note that to have Spotify also correctly paused and resumed, it needs to have the Spotify integration also installed in the system. + ```yaml - alias: 'Notify by TTS that Hanna has arrived Sound Room 1' id: tts_mary_home_sound_room1 @@ -209,6 +233,30 @@ Intrerupt playback of a source, incrase volume by 15%, say a TTS message and res entity_id: person.mary to: 'home' action: + - choose: + - conditions: + - condition: state + entity_id: media_player.sound_room1 + attribute: source + state: 'Spotify' + sequence: + - service: media_player.media_pause + target: + entity_id: media_player.spotify + - service: linkplay.snapshot + data: + entity_id: media_player.sound_room1 + switchinput: true + - service: media_player.volume_set + data: + entity_id: media_player.sound_room1 + volume_level: "{{ state_attr('media_player.sound_room1', 'volume_level') | float + 0.15 }}" + - service: tts.google_translate_say + data: + entity_id: media_player.sound_room1 + message: "Hanna has arrived home." + language: en + default: - service: linkplay.snapshot data: entity_id: media_player.sound_room1 @@ -219,8 +267,8 @@ Intrerupt playback of a source, incrase volume by 15%, say a TTS message and res - service: tts.google_translate_say data: entity_id: media_player.sound_room1 - language: en message: "Hanna has arrived home." + language: en - alias: 'Restore state after TTS for snapshotted Sound Room 1' id: tts_restore_sound_room1 @@ -235,29 +283,35 @@ Intrerupt playback of a source, incrase volume by 15%, say a TTS message and res attribute: snapshot_active state: true action: + - choose: + - conditions: + condition: and + conditions: + - condition: state + entity_id: media_player.spotify + state: 'paused' + - condition: state + entity_id: media_player.spotify + attribute: source + state: 'Sound Room1' + - condition: state + entity_id: media_player.sound_room1 + attribute: snapshot_spotify + state: true + sequence: + - service: linkplay.restore + data: + entity_id: media_player.sound_room1 + - service: media_player.media_play_pause + target: + entity_id: media_player.spotify + default: - service: linkplay.restore data: entity_id: media_player.sound_room1 ``` -No need for any delays in the automations. Trigger on attribute `tts_active` goes _false_ when the player finishes playing the TTS stream, and if `snapshot_active` is _true_ means that snapshot exists and can be restored. - -Play a sound file located on an http server or a webradio stream: -```yaml - - service: media_player.play_media - data: - entity_id: media_player.sound_room1 - media_content_id: 'http://icecast.streamserver.tld/mountpoint.mp3' - media_content_type: url -``` +No need for any delays in the automations. Trigger on attribute `tts_active` goes _false_ when the player finishes playing the TTS stream, and if `snapshot_active` is _true_ means that snapshot exists and can be restored. Also resumes Spotify playback if that's the case. -Play the first sound file located on the local storage directly attached to the device (folder\files order seen by the chip seems to be alphabetic): -```yaml - - service: media_player.play_media - data: - entity_id: media_player.sound_room1 - media_content_id: '1' - media_content_type: music -``` Select an input and set volume and unmute via an automation: ```yaml diff --git a/custom_components/linkplay/manifest.json b/custom_components/linkplay/manifest.json index 27da07f..5cc2e3b 100644 --- a/custom_components/linkplay/manifest.json +++ b/custom_components/linkplay/manifest.json @@ -1,11 +1,12 @@ { "domain": "linkplay", "name": "Linkplay", - "version":"3.1.4", + "version":"3.1.5", "documentation": "https://github.com/nagyrobi/home-assistant-custom-components-linkplay", "issue_tracker": "https://github.com/nagyrobi/home-assistant-custom-components-linkplay/issues", "after_dependencies": ["http", "tts", "media_source"], "config_flow": false, + "iot_class": "local_polling", "codeowners": [ "@nicjo814", "@limych", diff --git a/custom_components/linkplay/media_player.py b/custom_components/linkplay/media_player.py index 15d051b..3bc47b2 100644 --- a/custom_components/linkplay/media_player.py +++ b/custom_components/linkplay/media_player.py @@ -108,6 +108,7 @@ ATTR_UUID = 'uuid' ATTR_TTS = 'tts_active' ATTR_SNAPSHOT = 'snapshot_active' +ATTR_SNAPSPOT = 'snapshot_spotify' ATTR_DEBUG = 'debug_info' CONF_NAME = 'name' @@ -185,11 +186,12 @@ '48': 'XLR', '49': 'HDMI', '50': 'cd', + '51': 'Soundcard', '52': 'TFcard', '60': 'Talk', '99': 'Idle'} -SOURCES_LIVEIN = ['-1', '0', '40', '41', '43', '44', '45', '46', '47', '48', '49', '50', '99'] +SOURCES_LIVEIN = ['-1', '0', '40', '41', '43', '44', '45', '46', '47', '48', '49', '50', '51', '99'] SOURCES_STREAM = ['1', '2', '3', '10', '30'] SOURCES_LOCALF = ['11', '16', '20', '21', '52', '60'] @@ -381,9 +383,12 @@ def __init__(self, self._snap_state = STATE_UNKNOWN self._snap_volume = 0 self._snap_spotify = False + self._snap_spotify_volumeonly = False self._snap_nometa = False self._snap_playing_mediabrowser = False self._snap_media_source_uri = None + self._snap_seek = False + self._snap_playhead_position = 0 async def async_added_to_hass(self): """Record entity.""" @@ -606,7 +611,10 @@ async def async_update(self): self._first_update = False self._position_updated_at = utcnow() - + + if self._player_statdata['type'] == '0': + self._slave_mode = False + if self._multiroom_group == []: self._slave_mode = False self._is_master = False @@ -624,12 +632,14 @@ async def async_update(self): self._shuffle = { '2': True, '3': True, + '5': True, }.get(self._player_statdata['loop'], False) self._repeat = { + '0': REPEAT_MODE_ALL, '1': REPEAT_MODE_ONE, '2': REPEAT_MODE_ALL, - '0': REPEAT_MODE_ALL, + '5': REPEAT_MODE_ONE, }.get(self._player_statdata['loop'], REPEAT_MODE_OFF) # self._state = { @@ -817,6 +827,7 @@ async def async_update(self): # Get multiroom slave information # + slave_list = await self.call_linkplay_httpapi("multiroom:getSlaveList", True) if slave_list is None: self._is_master = False @@ -1096,6 +1107,7 @@ def extra_state_attributes(self): attributes[ATTR_TTS] = self._playing_tts attributes[ATTR_SNAPSHOT] = self._snapshot_active + attributes[ATTR_SNAPSPOT] = self._snap_spotify if DEBUGSTR_ATTR: atrdbg = "" @@ -1292,6 +1304,7 @@ async def async_media_stop(self): async def async_media_seek(self, position): """Send media_seek command to media player.""" if not self._slave_mode: + _LOGGER.debug("Seek. Device: %s, DUR: %s POS: %", self.name, self._duration, position) if self._duration > 0 and position >= 0 and position <= self._duration: value = await self.call_linkplay_httpapi("setPlayerCmd:seek:{0}".format(str(position)), None) self._position_updated_at = utcnow() @@ -1371,6 +1384,10 @@ async def async_play_media(self, media_type, media_id, **kwargs): _LOGGER.debug("For: %s, Detected M3U list: %s, Media_id: %s", self._name, media_id) media_id = await self.async_parse_m3u_url(media_id) + if media_id_check.endswith('.pls'): + _LOGGER.debug("For: %s, Detected PLS list: %s, Media_id: %s", self._name, media_id) + media_id = await self.async_parse_pls_url(media_id) + if media_type == MEDIA_TYPE_URL: if self._playing_mediabrowser: media_id_final = media_id @@ -1956,20 +1973,63 @@ async def async_parse_m3u_url(self, playlist): if response.status == HTTPStatus.OK: data = await response.text() - _LOGGER.debug("For: %s playlist: %s contents: %s", self._name, playlist, data) + _LOGGER.debug("For: %s M3U playlist: %s contents: %s", self._name, playlist, data) lines = [line.strip("\n\r") for line in data.split("\n") if line.strip("\n\r") != ""] if len(lines) > 0: - _LOGGER.debug("For: %s playlist: %s lines: %s", self._name, playlist, lines) + _LOGGER.debug("For: %s M3U playlist: %s lines: %s", self._name, playlist, lines) urls = [u for u in lines if u.startswith('http')] - _LOGGER.debug("For: %s playlist: %s urls: %s", self._name, playlist, urls) + _LOGGER.debug("For: %s M3U playlist: %s urls: %s", self._name, playlist, urls) if len(urls) > 0: return urls[0] else: - _LOGGER.error("For: %s playlist: %s No valid http URL in the playlist!!!", self._name, playlist) + _LOGGER.error("For: %s M3U playlist: %s No valid http URL in the playlist!!!", self._name, playlist) + self._nometa = True + else: + _LOGGER.error("For: %s M3U playlist: %s No content to parse!!!", self._name, playlist) + + else: + _LOGGER.error( + "For: %s (%s) Get failed, response code: %s Full message: %s", + self._name, + self._host, + response.status, + response, + ) + + return playlist + + async def async_parse_pls_url(self, playlist): + """Parse a PLS playlist URL for actual streams, and return the first one""" + try: + websession = async_get_clientsession(self.hass) + async with async_timeout.timeout(10): + response = await websession.get(playlist) + + except (asyncio.TimeoutError, aiohttp.ClientError) as error: + _LOGGER.warning( + "For: %s unable to get the PLS playlist: %s", self._name, playlist + ) + return playlist + + if response.status == HTTPStatus.OK: + data = await response.text() + _LOGGER.debug("For: %s PLS playlist: %s contents: %s", self._name, playlist, data) + + lines = [line.strip("\n\r") for line in data.split("\n") if line.strip("\n\r") != ""] + if len(lines) > 0: + _LOGGER.debug("For: %s PLS playlist: %s lines: %s", self._name, playlist, lines) + urls = [u for u in lines if u.startswith('File')] + _LOGGER.debug("For: %s PLS playlist: %s urls: %s", self._name, playlist, urls) + if len(urls) > 0: + url = urls[0].split('=') + if len(url) > 1: + return url[1] + else: + _LOGGER.error("For: %s PLS playlist: %s No valid http URL in the playlist!!!", self._name, playlist) self._nometa = True else: - _LOGGER.error("For: %s playlist: %s No content to parse!!!", self._name, playlist) + _LOGGER.error("For: %s PLS playlist: %s No content to parse!!!", self._name, playlist) else: _LOGGER.error( @@ -1982,7 +2042,7 @@ async def async_parse_m3u_url(self, playlist): return playlist - def _fwvercheck(self, v): #no async yet + def _fwvercheck(self, v): filled = [] for point in v.split("."): filled.append(point.zfill(8)) @@ -2339,23 +2399,36 @@ async def async_snapshot(self, switchinput): return if not self._slave_mode: - _LOGGER.debug("Player %s snaphsot source: %s, volume: %s, and uri to: %s", self.name, self._source, self._snap_volume, self._media_uri_final) - self._snap_source = self._source self._snapshot_active = True + self._snap_source = self._source self._snap_state = self._state self._snap_nometa = self._nometa self._snap_playing_mediabrowser = self._playing_mediabrowser self._snap_media_source_uri = self._media_source_uri + self._snap_playhead_position = self._playhead_position + + if self._playing_localfile or self._playing_spotify or self._playing_webplaylist: + if self._state in [STATE_PLAYING, STATE_PAUSED]: + self._snap_seek = True + + elif self._playing_stream or self._playing_mediabrowser: + if self._state in [STATE_PLAYING, STATE_PAUSED] and self._playing_mediabrowser: + self._snap_seek = True + + _LOGGER.debug("Player %s snaphsot source: %s, volume: %s, uri: %s, seek: %s, pos: %s", self.name, self._source, self._snap_volume, self._media_uri_final, self._snap_seek, self._playhead_position) if self._source == "Network": self._snap_uri = self._media_uri_final if self._playing_spotify: - await self.async_preset_snap_via_upnp(str(self._preset_key)) + if not switchinput: + await self.async_preset_snap_via_upnp(str(self._preset_key)) + await self.call_linkplay_httpapi("setPlayerCmd:stop", None) + else: + self._snap_spotify_volumeonly = True self._snap_spotify = True self._snap_volume = int(self._volume) - await self.call_linkplay_httpapi("setPlayerCmd:stop", None) # await asyncio.sleep(0.2) return @@ -2395,10 +2468,9 @@ async def async_restore(self): return if not self._slave_mode: - _LOGGER.debug("Player %s current source: %s, restoring volume: %s, source: %s uri: %s", self.name, self._source, self._snap_volume, self._snap_source, self._snap_uri) + _LOGGER.debug("Player %s current source: %s, restoring volume: %s, source: %s uri: %s, seek: %s, pos: %s", self.name, self._source, self._snap_volume, self._snap_source, self._snap_uri, self._snap_seek, self._snap_playhead_position) if self._snap_state != STATE_UNKNOWN: self._state = self._snap_state - self._snap_state = STATE_UNKNOWN if self._snap_volume != 0: await self.call_linkplay_httpapi("setPlayerCmd:vol:{0}".format(str(self._snap_volume)), None) @@ -2407,11 +2479,14 @@ async def async_restore(self): # await asyncio.sleep(.6) self._playing_tts = False + self._playhead_position = self._snap_playhead_position if self._snap_spotify: self._snap_spotify = False - await self.call_linkplay_httpapi("MCUKeyShortClick:{0}".format(str(self._preset_key)), None) + if not self._snap_spotify_volumeonly: + await self.call_linkplay_httpapi("MCUKeyShortClick:{0}".format(str(self._preset_key)), None) self._snapshot_active = False + self._snap_spotify_volumeonly = False # await self.async_schedule_update_ha_state(True) elif self._snap_source != "Network": @@ -2424,11 +2499,23 @@ async def async_restore(self): self._media_source_uri = self._snap_media_source_uri self._media_uri = self._snap_uri self._nometa = self._snap_nometa - await self.async_play_media(MEDIA_TYPE_URL, self._snap_uri) - await asyncio.sleep(1) + if self._snap_state in [STATE_PLAYING, STATE_PAUSED]: # self._media_uri.find('tts_proxy') == -1 + await self.async_play_media(MEDIA_TYPE_URL, self._media_uri) self._snapshot_active = False self._snap_uri = None + if self._snap_state in [STATE_PLAYING, STATE_PAUSED]: + await asyncio.sleep(0.5) + if self._snap_seek and self._snap_playhead_position > 0: + _LOGGER.debug("Seekin'") + await self.call_linkplay_httpapi("setPlayerCmd:seek:{0}".format(str(self._snap_playhead_position)), None) + if self._snap_state == STATE_PAUSED: + await self.async_media_pause() + + self._snap_state = STATE_UNKNOWN + self._snap_seek = False + self._snap_playhead_position = 0 + else: return #await self._master.async_restore() @@ -2491,8 +2578,8 @@ async def async_update_via_upnp(self): media_metadata = None try: media_info = await self._service.action("GetMediaInfo").async_call(InstanceID=0) - self._trackc = media_info.get('TrackSource') - self._media_uri_final = media_info.get('CurrentURI') + self._trackc = media_info.get('CurrentURI') + self._media_uri_final = media_info.get('TrackSource') media_metadata = media_info.get('CurrentURIMetaData') #_LOGGER.debug("GetMediaInfo for: %s, UPNP media_metadata:%s", self.entity_id, media_info) except: diff --git a/custom_components/linkplay/services.yaml b/custom_components/linkplay/services.yaml index 6d0d327..b290716 100644 --- a/custom_components/linkplay/services.yaml +++ b/custom_components/linkplay/services.yaml @@ -45,11 +45,11 @@ snapshot: entity: integration: linkplay switchinput: - name: Switch Input - description: Switch player to stream input along with snapshotting, before playing TTS. Applies for players with multiple inputs like Line-in, Optical, etc. Useful to try to handle the situation when first few seconds of the TTS message are cut off due to the latency of the player. Optional - if not specified, defaults to True. + name: Switch Input / Volume Only + description: To be used with Spotify Integration. Switch player to stream input along with snapshotting, before playing TTS. Applies for players with multiple inputs like Line-in, Optical, etc. Optional - if not specified, defaults to True. example: false required: false - default: true + default: false selector: boolean: