diff --git a/custom_components/nvidia_shield/media_player.py b/custom_components/nvidia_shield/media_player.py index 7ba183c..e02c7f6 100644 --- a/custom_components/nvidia_shield/media_player.py +++ b/custom_components/nvidia_shield/media_player.py @@ -16,6 +16,7 @@ Intent tracking bridges the gap between instant commands and slow Jellyfin polli 4. If Jellyfin doesn't confirm in 30s → retry once → if still no → surrender to truth """ +import asyncio import logging import time @@ -40,13 +41,17 @@ from .const import ( ATTR_MEDIA_DAG_RECEIVER_INPUT, ATTR_MEDIA_DAG_ROLE, ATTR_MEDIA_DAG_RUNNING_APPS, + CHANNEL_DIGIT_DELAY, CONF_ADB_STORE_APP_INFO, CONF_ANDROIDTV_ENTITY, + CONF_CHANNEL_SEND_ENTER, CONF_JELLYFIN_ENTITY, CONF_POWER_ENTITY, CONF_RECEIVER_ENTITY, CONF_RECEIVER_INPUT, DOMAIN, + PROTOCOL_MEDIA_KEYS, + PROTOCOL_REMOTE_KEYS, ) from .ha_adb_common import PlaybackIntent, running_apps_from_tasks from .jellyfin import fetch_session_media_info @@ -69,6 +74,7 @@ _BASE_FEATURES = ( | MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.VOLUME_STEP | MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.PLAY_MEDIA ) _PLAYBACK_FEATURES = ( @@ -117,6 +123,9 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity): # (connection.py) — this entity is a thin callback-view over its store. self._manager: ShieldConnectionManager | None = None self._manager_unsub: CALLBACK_TYPE | None = None + # In-flight channel digit entry (play_media(channel) — §7); a new + # play_media cancels the previous sequence (core task-cancel guard). + self._channel_task = None # Entity-owned background tasks (intent retries, media-info fetch); # cancelled on unload. The manager owns the socket — never touch it # here directly. @@ -259,6 +268,15 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity): intent.command, jf_state, intent.target_state, intent.retry_count, ) if ( + intent.via_protocol + and intent.protocol_key + and self._manager is not None + ): + # The command was served by the protocol key push — retry there. + self._track_task( + self._manager.send_protocol_key(intent.protocol_key) + ) + elif ( intent.via_adb and intent.adb_command and self._manager is not None @@ -358,6 +376,7 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity): task = self.hass.async_create_task(coro) self._entity_tasks.add(task) task.add_done_callback(self._entity_tasks.discard) + return task async def _fetch_media_info(self, entity_picture: str) -> None: """Read stream info from the HA Jellyfin integration's coordinator.""" @@ -387,12 +406,17 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity): ADB is the authority — it knows if the device is awake or asleep. The plug being "on" just means the outlet has power (standby). """ - # 1. ADB wake state — most accurate (exact mWakefulness parse) + # 1. Protocol is_on push — fastest truth when the transport is live + protocol_is_on = self._manager.is_on if self._manager else None + if protocol_is_on is not None: + return protocol_is_on + + # 2. ADB wake state — exact mWakefulness parse adb_awake = self._manager.awake if self._manager else None if adb_awake is not None: return adb_awake - # 2. Power entity — only trust "off" (plug pulled = definitely off) + # 3. Power entity — only trust "off" (plug pulled = definitely off) if self._power_entity: power_state = self._read(self._power_entity) if power_state and power_state.state == "off": @@ -411,7 +435,10 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity): power = self._is_power_on() if power is False: - return MediaPlayerState.IDLE # ADB says asleep — standby (yellow dot) + if self._manager is not None and self._manager.is_on is False: + return MediaPlayerState.OFF # protocol says powered off + # ADB says asleep — standby (yellow dot) + return MediaPlayerState.IDLE if power is None: return MediaPlayerState.IDLE @@ -501,6 +528,30 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity): def media_image_url(self): return self._jf_attr("entity_picture") if self._jf_active() else None + @property + def volume_level(self) -> float | None: + """Protocol volume push readout (C-VOL — level only, no VOLUME_SET).""" + if self._manager is None: + return None + info = self._manager.volume_info + if info is None: + return None + level, volume_max, _muted = info + try: + if volume_max and volume_max > 0: + return level / volume_max + except (TypeError, ValueError): + pass + return None + + @property + def is_volume_muted(self) -> bool | None: + """Real mute state from the protocol push (was unknowable pre-Phase 2).""" + if self._manager is None: + return None + info = self._manager.volume_info + return info[2] if info is not None else None + def _get_now_playing(self) -> dict | None: if not self._jf_active(): return None @@ -629,18 +680,37 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity): await self._cmd("turn_off") async def async_volume_up(self): - await self._cmd("volume_up") + await self._remote_key_cmd("volume_up", "volume_up") async def async_volume_down(self): - await self._cmd("volume_down") + await self._remote_key_cmd("volume_down", "volume_down") async def async_mute_volume(self, mute): - await self._cmd("volume_mute", is_volume_muted=mute) + """Mute via the protocol/ADB key — no protocol VOLUME_SET exists (C-VOL).""" + await self._remote_key_cmd("volume_mute", "mute") + + async def _remote_key_cmd(self, remote_key: str, adb_key: str) -> None: + """Simple key command without intent bookkeeping: protocol → ADB → legacy.""" + protocol_key = PROTOCOL_REMOTE_KEYS.get(remote_key) + if ( + protocol_key + and self._manager is not None + and await self._manager.send_protocol_key(protocol_key) + ): + return + if self._manager is not None and await self._manager.send_media_command(adb_key): + return + # Transitional: external androidtv_remote entity (removed in Phase 4) + await self._cmd(remote_key) async def _media_cmd( self, ha_service: str, adb_command: str, intent_state: MediaPlayerState | None = None ) -> None: - """Send a media command. Uses ADB dispatch for non-Jellyfin apps when available.""" + """Send a media command over the fastest available command path. + + Order (Phase 2): protocol key push → ADB keyevent → the external + androidtv_remote entity (transitional until Phase 4 removes it). + """ if intent_state: self._set_intent(ha_service, intent_state) self.async_write_ha_state() @@ -649,6 +719,19 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity): jf = self._read(self._jellyfin_entity) jf_active = jf and jf.state in (STATE_PLAYING, STATE_PAUSED) + # 1. Protocol key push (fast; works whenever the transport is up) + protocol_key = PROTOCOL_MEDIA_KEYS.get(ha_service) + if ( + protocol_key + and self._manager is not None + and await self._manager.send_protocol_key(protocol_key) + ): + if self._intent is not None: + self._intent.via_protocol = True + self._intent.protocol_key = protocol_key + return + + # 2. ADB keyevent (socket-independent; exact same commands as before) if self._manager and self._manager.connected and not jf_active: success = await self._manager.send_media_command(adb_command) if success: @@ -659,7 +742,7 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity): self._intent.adb_command = adb_command return - # Fall back to Android TV Remote + # 3. Transitional: the external androidtv_remote entity (removed in Phase 4) await self._cmd(ha_service) async def async_media_play(self): @@ -683,3 +766,76 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity): _LOGGER.warning("ADB not available for bring_to_foreground") return False return await self._manager.bring_to_foreground(package) + + # ── play_media (§7): channel digits, app/url launch ── + + async def async_play_media(self, media_type: str, media_id: str) -> None: + """Channel selection by digit number; app/url launches (C-LAUNCH). + + Channel path replicates HA core androidtv_remote exactly: digits only, + configurable inter-digit delay (default 0.1 s), no ENTER — and the + padded prior in-flight channel sequence is cancelled first. + """ + if media_type == "channel": + digits = [c for c in media_id if c.isdigit()] + if not digits: + _LOGGER.warning("play_media(channel): no digits in %r", media_id) + return + self._cancel_inflight_channel() + self._channel_task = self._track_task( + self._channel_entry_sequence(digits) + ) + return + + if media_type in ("app", "url"): + if self._manager is not None and await self._manager.launch_app(media_id): + return + if ( + media_type == "app" + and self._manager is not None + and await self._manager.bring_to_foreground(media_id) + ): + # Fallback: monkey launch of the bare package (no deep links). + return + _LOGGER.warning( + "play_media(%s): could not launch %s — no available transport", + media_type, + media_id, + ) + return + + _LOGGER.warning( + "play_media: unsupported media type %r (supported: channel/app/url)" + " — nothing sent", + media_type, + ) + + def _cancel_inflight_channel(self) -> None: + task = self._channel_task + if task is not None and not task.done(): + task.cancel() + self._channel_task = None + + async def _channel_entry_sequence(self, digits: list[str]) -> None: + delay = self._entry.options.get("channel_digit_delay", CHANNEL_DIGIT_DELAY) + send_enter = self._entry.options.get(CONF_CHANNEL_SEND_ENTER, False) + try: + for digit in digits: + # 1. Protocol digit key; 2. ADB keyevent 7..16; 3. legacy entity + served = False + if self._manager is not None: + served = await self._manager.send_protocol_key(f"KEYCODE_{digit}") + if not served: + served = await self._manager.send_digit_key(digit) + if not served: + await self._cmd( + "play_media", + media={"media_content_id": digit, "media_content_type": "channel"}, + ) + await asyncio.sleep(delay) + if send_enter: + _LOGGER.debug("play_media(channel): optional ENTER key sent") + await self._manager.send_protocol_key("KEYCODE_ENTER") + except asyncio.CancelledError: + raise +