diff --git a/custom_components/nvidia_shield/ha_adb_common/media_session.py b/custom_components/nvidia_shield/ha_adb_common/media_session.py index 88ecbd6..de48696 100644 --- a/custom_components/nvidia_shield/ha_adb_common/media_session.py +++ b/custom_components/nvidia_shield/ha_adb_common/media_session.py @@ -17,6 +17,12 @@ class MediaSessionInfo: **device uptime-ms** timestamp (verified on Shield Android 11, R9), not a wallclock value. Use :meth:`position_at_uptime` with the device's current uptime to extrapolate the live position. + + ``playing``/``paused``/``buffering`` are independent flags mapped from the + Android PlaybackState integer: 2 = paused, 3 = playing, 6 = buffering + (t55). The Shield's own ExoPlayer/MediaSession sessions report state 6 + while buffering just as stock Android does, so no vendor-specific state + integer is treated as buffering. """ title: str | None = None @@ -25,6 +31,7 @@ class MediaSessionInfo: package: str | None = None playing: bool = False paused: bool = False + buffering: bool = False position: float | None = None speed: float | None = None updated: int | None = None @@ -81,7 +88,8 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]: """Parse `dumpsys media_session` output for ALL active media sessions. Returns a list of every active session that has metadata or a known - playback state, sorted: playing first, then paused, then others. + playback state, sorted: playing first, then buffering, then paused, then + others (t55). """ if not dump: return [] @@ -89,7 +97,8 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]: sessions: list[MediaSessionInfo] = [] current_pkg: str | None = None in_active = False - current_state = 0 # 0=unknown, 2=paused, 3=playing + # Android PlaybackState: 0=unknown, 2=paused, 3=playing, 6=buffering + current_state = 0 current_position: float | None = None current_speed: float | None = None current_updated: int | None = None @@ -138,6 +147,7 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]: info = MediaSessionInfo() info.package = current_pkg info.playing = current_state == 3 + info.buffering = current_state == 6 info.paused = current_state == 2 info.position = current_position info.speed = current_speed @@ -158,13 +168,16 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]: current_meta.package = current_pkg sessions.append(current_meta) - # Sort: playing first, then paused, then others + # Sort: playing first, then buffering (mid-playback), then paused, then + # others — a buffering session outranks a paused one (t55). def sort_key(s: MediaSessionInfo) -> int: if s.playing: return 0 - if s.paused: + if s.buffering: return 1 - return 2 + if s.paused: + return 2 + return 3 sessions.sort(key=sort_key) return sessions @@ -206,8 +219,9 @@ def parse_media_session( Returns the best match from all active sessions: 1. The session whose package matches the foreground app (if provided) 2. The session that is currently playing (state=3) - 3. The session that is paused (state=2) - 4. Any active session with metadata + 3. The session that is buffering (state=6) + 4. The session that is paused (state=2) + 5. Any active session with metadata """ sessions = parse_all_media_sessions(dump) if not sessions: @@ -218,6 +232,5 @@ def parse_media_session( for s in sessions: if s.package == foreground_package: return s - - # Already sorted: playing > paused > other + # Already sorted: playing > buffering > paused > other return sessions[0] diff --git a/custom_components/nvidia_shield/ha_adb_common/running_apps.py b/custom_components/nvidia_shield/ha_adb_common/running_apps.py index 948691d..aaf4e3b 100644 --- a/custom_components/nvidia_shield/ha_adb_common/running_apps.py +++ b/custom_components/nvidia_shield/ha_adb_common/running_apps.py @@ -51,8 +51,11 @@ def running_apps_from_tasks( if session: if session.playing: app["state"] = "playing" + elif session.buffering: + app["state"] = "buffering" elif session.paused: app["state"] = "paused" + app["state"] = "paused" else: app["state"] = "idle" if session.title: @@ -66,6 +69,8 @@ def running_apps_from_tasks( if cached is not None: if cached.playing: app["last_known_state"] = "playing" + elif cached.buffering: + app["last_known_state"] = "buffering" elif cached.paused: app["last_known_state"] = "paused" if cached.title: diff --git a/tests/entity_stublib.py b/tests/entity_stublib.py index fd08e17..807a2d6 100644 --- a/tests/entity_stublib.py +++ b/tests/entity_stublib.py @@ -79,6 +79,8 @@ class MediaPlayerState(enum.StrEnum): IDLE = "idle" PLAYING = "playing" PAUSED = "paused" + STANDBY = "standby" + BUFFERING = "buffering" class MediaPlayerEntityFeature(enum.IntFlag): diff --git a/tests/test_media_session.py b/tests/test_media_session.py index c0581c0..90c79b9 100644 --- a/tests/test_media_session.py +++ b/tests/test_media_session.py @@ -125,6 +125,76 @@ class TestPlaybackStatePosition: assert sessions[0].position is None +class TestPlaybackStateBufferingT55: + """t55: Android PlaybackState 6 is BUFFERING, mapped in the parser. + + The Shield's own sessions report the same integer stock Android does (6), + so no vendor-specific variant is treated as buffering — a device variant + would be a template field on the same PlaybackState line, never a + different integer. + """ + + def _buffering_dump(self, state=6, speed="0.0"): + return ( + "Sessions Stack - have 1 sessions:\n" + " Some app session com.netflix.ninja/Session (userId=0)\n" + " package=com.netflix.ninja\n" + " active=true\n" + f" state=PlaybackState {{state={state}, position=0, " + f"buffered position=2000, speed={speed}, updated=900, actions=1, " + "custom actions=[], active item id=0, error=null}\n" + " metadata: size=2, description=Movie Title, Some Artist\n" + ) + + def test_state_6_is_buffering(self): + sessions = parse_all_media_sessions(self._buffering_dump()) + assert len(sessions) == 1 + session = sessions[0] + assert session.buffering is True + assert session.playing is False + assert session.paused is False + assert session.title == "Movie Title" + + def test_state_3_and_2_stay_playing_and_paused(self): + for state, attr in ((3, "playing"), (2, "paused")): + sessions = parse_all_media_sessions(self._buffering_dump(state=state)) + session = sessions[0] + assert session.buffering is False + assert getattr(session, attr) is True + + def test_state_6_wins_when_the_dump_says_speed_zero_too(self): + """The buffering signal is the integer, not the speed field.""" + sessions = parse_all_media_sessions(self._buffering_dump(state=6)) + assert sessions[0].buffering is True + + def test_buffering_outranks_a_paused_session_in_the_pick_order(self): + sessions = parse_all_media_sessions( + self._buffering_dump() + + " Other app session com.example.player/Session (userId=0)\n" + " package=com.example.player\n" + " active=true\n" + " state=PlaybackState {state=2, position=1, speed=0.0, " + "updated=1, actions=1, custom actions=[], active item id=0, " + "error=null}\n" + " metadata: size=1, description=Paused Tune\n" + ) + assert [s.package for s in sessions] == [ + "com.netflix.ninja", + "com.example.player", + ] + # and parse_media_session (no foreground preference) picks buffering + dump = self._buffering_dump() + ( + " Other app session com.example.player/Session (userId=0)\n" + " package=com.example.player\n" + " active=true\n" + " state=PlaybackState {state=2, position=1, speed=0.0, " + "updated=1, actions=1, custom actions=[], active item id=0, " + "error=null}\n" + " metadata: size=1, description=Paused Tune\n" + ) + assert parse_media_session(dump).package == "com.netflix.ninja" + + class TestAudioPlaybackSection: """The "Audio playback (lastly played comes first)" section lists apps that recently played audio even with no MediaSession (R2).""" @@ -160,6 +230,29 @@ class TestAudioPlaybackSection: assert parse_audio_playback("") == [] +def test_running_apps_buffering_state_t55(): + """t55: a buffering session (and a buffering last-known) is named buffering.""" + from nsh.ha_adb_common import MediaSessionInfo, running_apps_from_tasks + + tasks = [ + {"package": "com.netflix.ninja"}, + {"package": "ar.tvplayer.tv"}, + ] + sessions = [MediaSessionInfo(package="com.netflix.ninja", buffering=True)] + last_known = { + "ar.tvplayer.tv": MediaSessionInfo(package="ar.tvplayer.tv", buffering=True) + } + + apps = running_apps_from_tasks( + tasks, sessions, foreground_package=None, last_known=last_known + ) + + netflix = next(a for a in apps if a["package"] == "com.netflix.ninja") + tivi = next(a for a in apps if a["package"] == "ar.tvplayer.tv") + assert netflix["state"] == "buffering" + assert tivi["last_known_state"] == "buffering" + + def test_running_apps_last_known_enrichment(): """P3-3b: apps without a live session carry their TTL-capped last-known state.""" from nsh.ha_adb_common import MediaSessionInfo, running_apps_from_tasks