diff --git a/homeassistant/components/apple_tv/browse_media.py b/homeassistant/components/apple_tv/browse_media.py new file mode 100644 index 00000000000..3c0eee8b6ad --- /dev/null +++ b/homeassistant/components/apple_tv/browse_media.py @@ -0,0 +1,44 @@ +"""Support for media browsing.""" + +from homeassistant.components.media_player import BrowseMedia +from homeassistant.components.media_player.const import ( + MEDIA_CLASS_APP, + MEDIA_CLASS_DIRECTORY, + MEDIA_TYPE_APP, + MEDIA_TYPE_APPS, +) + + +def build_app_list(app_list): + """Create response payload for app list.""" + app_list = [ + {"app_id": app_id, "title": app_name, "type": MEDIA_TYPE_APP} + for app_name, app_id in app_list.items() + ] + + return BrowseMedia( + media_class=MEDIA_CLASS_DIRECTORY, + media_content_id=None, + media_content_type=MEDIA_TYPE_APPS, + title="Apps", + can_play=True, + can_expand=False, + children=[item_payload(item) for item in app_list], + children_media_class=MEDIA_CLASS_APP, + ) + + +def item_payload(item): + """ + Create response payload for a single media item. + + Used by async_browse_media. + """ + return BrowseMedia( + title=item["title"], + media_class=MEDIA_CLASS_APP, + media_content_type=MEDIA_TYPE_APP, + media_content_id=item["app_id"], + can_play=False, + can_expand=False, + ) diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py index bc4697e0d5e..77c97a1b54b 100644 --- a/homeassistant/components/apple_tv/media_player.py +++ b/homeassistant/components/apple_tv/media_player.py @@ -1,6 +1,7 @@ """Support for Apple TV media player.""" import logging +from pyatv import exceptions from pyatv.const import ( DeviceState, FeatureName, @@ -12,14 +13,16 @@ from pyatv.const import ( ) from pyatv.helpers import is_streamable -from homeassistant.components.media_player import MediaPlayerEntity +from homeassistant.components.media_player import BrowseMedia, MediaPlayerEntity from homeassistant.components.media_player.const import ( + MEDIA_TYPE_APP, MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO, REPEAT_MODE_ALL, REPEAT_MODE_OFF, REPEAT_MODE_ONE, + SUPPORT_BROWSE_MEDIA, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, @@ -27,6 +30,7 @@ from homeassistant.components.media_player.const import ( SUPPORT_PREVIOUS_TRACK, SUPPORT_REPEAT_SET, SUPPORT_SEEK, + SUPPORT_SELECT_SOURCE, SUPPORT_SHUFFLE_SET, SUPPORT_STOP, SUPPORT_TURN_OFF, @@ -46,6 +50,7 @@ from homeassistant.core import callback import homeassistant.util.dt as dt_util from . import AppleTVEntity +from .browse_media import build_app_list from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -60,6 +65,7 @@ SUPPORT_BASE = SUPPORT_TURN_ON | SUPPORT_TURN_OFF # of these). SUPPORT_APPLE_TV = ( SUPPORT_BASE + | SUPPORT_BROWSE_MEDIA | SUPPORT_PLAY_MEDIA | SUPPORT_PAUSE | SUPPORT_PLAY @@ -89,6 +95,8 @@ SUPPORT_FEATURE_MAPPING = { FeatureName.SetRepeat: SUPPORT_REPEAT_SET, FeatureName.SetShuffle: SUPPORT_SHUFFLE_SET, FeatureName.SetVolume: SUPPORT_VOLUME_SET, + FeatureName.AppList: SUPPORT_BROWSE_MEDIA | SUPPORT_SELECT_SOURCE, + FeatureName.LaunchApp: SUPPORT_BROWSE_MEDIA | SUPPORT_SELECT_SOURCE, } @@ -108,6 +116,7 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): """Initialize the Apple TV media player.""" super().__init__(name, identifier, manager, **kwargs) self._playing = None + self._app_list = {} @callback def async_device_connected(self, atv): @@ -135,6 +144,21 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): # Listen to power updates self.atv.power.listener = self + if self.atv.features.in_state(FeatureState.Available, FeatureName.AppList): + self.hass.create_task(self._update_app_list()) + + async def _update_app_list(self): + _LOGGER.debug("Updating app list") + try: + apps = await self.atv.apps.app_list() + except exceptions.NotSupportedError: + _LOGGER.error("Listing apps is not supported") + except exceptions.ProtocolError: + _LOGGER.exception("Failed to update app list") + else: + self._app_list = {app.name: app.identifier for app in apps} + self.async_write_ha_state() + @callback def async_device_disconnected(self): """Handle when connection was lost to device.""" @@ -198,6 +222,11 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): return self.atv.metadata.app.name return None + @property + def source_list(self): + """List of available input sources.""" + return list(self._app_list.keys()) + @property def media_content_type(self): """Content type of current playing media.""" @@ -248,7 +277,9 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): """Send the play_media command to the media player.""" # If input (file) has a file format supported by pyatv, then stream it with # RAOP. Otherwise try to play it with regular AirPlay. - if self._is_feature_available(FeatureName.StreamFile) and ( + if media_type == MEDIA_TYPE_APP: + await self.atv.apps.launch_app(media_id) + elif self._is_feature_available(FeatureName.StreamFile) and ( await is_streamable(media_id) or media_type == MEDIA_TYPE_MUSIC ): _LOGGER.debug("Streaming %s via RAOP", media_id) @@ -346,6 +377,14 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): return self.atv.features.in_state(FeatureState.Available, feature) return False + async def async_browse_media( + self, + media_content_type=None, + media_content_id=None, + ) -> BrowseMedia: + """Implement the websocket media browsing helper.""" + return build_app_list(self._app_list) + async def async_turn_on(self): """Turn the media player on.""" if self._is_feature_available(FeatureName.TurnOn): @@ -425,3 +464,8 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): await self.atv.remote_control.set_shuffle( ShuffleState.Songs if shuffle else ShuffleState.Off ) + + async def async_select_source(self, source: str) -> None: + """Select input source.""" + if app_id := self._app_list.get(source): + await self.atv.apps.launch_app(app_id)