fix(media_player): the state ladder is idle/playing/buffering/off; ON means no data (t55)

The live report: the card showed "On" where the user expected Idle,
Playing, Buffering or Off, and "On" appeared to override playing states.

Root cause: `state` returned MediaPlayerState.ON for two different
situations — "powered on with an app open and nothing playing" (tier 3.5)
and "we have no idea" (the fallthrough) — while the power gate above the
media tiers returned IDLE whenever power resolved to None, which is
exactly the case in which Jellyfin a media session DO know what is
playing. Media truth was therefore lost whenever the protocol push and
the ADB wake poll had not reported yet.

The ladder is now:

  1. OFF     — the power entity says off, or the protocol is_on push is
               False. Explicit power outranks media, so a stale Jellyfin
               session cannot resurrect playback on a device that is off.
  2. intent  — a just-sent play/pause/stop.
  3. Jellyfin — playing / paused.
  4. session  — the ADB media session: buffering / playing / paused.
  5. IDLE     — ADB standby (powered but asleep), or the device is on
               (protocol is_on / ADB awake / the plug) with nothing
               playing. "Powered on, app open, nothing playing" is IDLE.
  6. ON       — nothing reported anything at all: no transport, no power
               entity, no Jellyfin, no session. ON never masks a
               playing/paused/buffering source and never appears while
               any source reported a power or media state.

Buffering also carries the playback controls: a buffering session
advertises PLAY/PAUSE/STOP exactly like a playing one, and the
now-playing dict names it "buffering" instead of collapsing it into
"playing" and contradicting the entity state.

The module docstring documents the ladder and the ON-only-with-no-data
rule, and the new TestStateModelT55 / TestCurrentAppNameEndToEndT55 pin
one test per source (Jellyfin, intent, media session, power entity,
protocol push, ADB wake) that a reporting source outranks ON/IDLE.

Red-proof against the previous tree (new tests, old source): 21 failed /
17 passed, with the three headline symptoms visible as literal asserts —
"on" where "idle" belongs, "idle" where "on" belongs (no data), and
"playing" lost because power was unknown.
This commit is contained in:
James Mackay committed 2026-10-12 06:53:04 +01:00
1 parent 07682ff082
commit ccde3cff71
2 files changed
+605 -43

No files matched your search

+118 -37
View File
@@ -13,6 +13,31 @@ State architecture:
keyevent. The external androidtv_remote entity tier that used to back this is
gone — t41 removed it with the entity — so a command has exactly one owner.
State ladder (t55) — what ``state`` reports, in order. The vocabulary is
EXACTLY ``{playing, paused, buffering, idle, off, on}``; each step is the first
source that has something to say, and the earlier steps always win:
1. OFF — the configured power entity reads "off", or the protocol's
is_on push is False (the device reporting its own power
state). Both OUTRANK media: a stale Jellyfin session must not
resurrect playback on a device that is off.
2. intent — a play/pause/stop this entity just sent (optimistic UI until
a slow Jellyfin poll confirms it; self-heal in 10 s).
3. Jellyfin — STATE_PLAYING / STATE_PAUSED of the configured entity.
4. session — the ADB media session's PlaybackState, including BUFFERING
(Android state 6). Session state 3 → playing, 2 → paused.
5. IDLE — ADB says the device is asleep (standby), OR the device is
powered on (protocol is_on True / ADB awake / the plug is on)
and NOTHING is playing. "Powered on, app open, nothing
playing" is IDLE for exactly this reason.
6. ON — NOTHING reported anything at all: no transport connected, no
power entity, no Jellyfin, no media session, no intent. ON is
the "we cannot know" state and the only situation it may
describe. It must NEVER appear while any source reported a
power or media state, and it must never mask a
playing/paused/buffering source — the rule that fixes the live
"On overrides my playing states" report.
Intent tracking bridges the gap between instant commands and slow Jellyfin polling:
1. User presses pause → intent recorded → UI shows paused immediately
2. Command sent over the serving transport → Shield pauses on screen
@@ -623,30 +648,55 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
return None
@staticmethod
def _session_state(info) -> MediaPlayerState | None:
"""The MediaPlayerState a media session is reporting, or None (t55).
One place decides what a session means, so ``state`` and
``supported_features`` cannot disagree about it. Buffering is checked
FIRST: a session refilling its buffer is mid-playback, and while the
Shield never sets state 6 together with 3, BUFFERING is the more
informative of the two if a future firmware ever did.
"""
if info is None:
return None
if info.buffering:
return MediaPlayerState.BUFFERING
if info.playing:
return MediaPlayerState.PLAYING
if info.paused:
return MediaPlayerState.PAUSED
return None
@property
def state(self) -> MediaPlayerState:
# Check if plug is off first — truly off
"""The playback state — the ladder is documented in the module docstring.
Exactly ``{playing, paused, buffering, idle, off}`` are reported for a
device we have data about; ``ON`` is reserved for the case where NO
source reported anything at all (no transport connected, no power and
no media data) and must never mask a playing/paused/buffering source.
A powered-on Shield with an app open but nothing playing reads IDLE,
not ON (t55 — the live defect users read as "On overrides my playing
states").
"""
# 1. Explicit OFF. The plug being off is definitive; the protocol's
# is_on push is the device reporting its own power state. Either
# one OUTRANKS every media source — a stale Jellyfin session must
# never resurrect playback on a device that is off.
if self._power_entity:
power_state = self._read(self._power_entity)
if power_state and power_state.state == "off":
return MediaPlayerState.OFF
manager = self._manager
if manager is not None and manager.is_on is False:
return MediaPlayerState.OFF
power = self._is_power_on()
if power is False:
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
# Tier 1: Active intent overrides Jellyfin
# 2. A user-commanded intent is the freshest media truth we have.
if self._intent is not None:
return MediaPlayerState(self._intent.target_state)
# Tier 2: Jellyfin is authoritative
# 3. Jellyfin (slow, but authoritative for its own sessions).
jf = self._read(self._jellyfin_entity)
if jf:
if jf.state == STATE_PLAYING:
@@ -654,22 +704,28 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
if jf.state == STATE_PAUSED:
return MediaPlayerState.PAUSED
# Tier 3: ADB media session (non-Jellyfin apps)
adb_info = self._manager.best_session(self.app_id) if self._manager else None
if adb_info:
if adb_info.playing:
return MediaPlayerState.PLAYING
if adb_info.paused:
return MediaPlayerState.PAUSED
# 4. Live media session (non-Jellyfin apps), buffering included.
session = manager.best_session(self.app_id) if manager is not None else None
session_state = self._session_state(session)
if session_state is not None:
return session_state
# Tier 3.5 (P3-3): foreground fallback — an app is open (protocol
# current_app push, else the ADB mResumedActivity parse) but publishes
# no media session. The device is still ON; the now-playing tier
# carries the last-known/channel/audio-hint identity.
if self._manager is not None and self._manager.foreground_app:
return MediaPlayerState.ON
# 5. ADB standby: powered but asleep — still data (the device
# answered), so IDLE, never ON. media_player.STANDBY is deprecated
# (2025-07), so IDLE is the compliant mapping (t22 audit).
if manager is not None and manager.awake is False:
return MediaPlayerState.IDLE
# Tier 4: On but no media
# 6. The device is on (protocol is_on True, ADB awake, or the power
# plug is on — every one of those is a source that reported
# something). An open app or an awake device reads IDLE, not ON.
power = self._is_power_on()
if power is not None or self._power_entity:
return MediaPlayerState.IDLE
# 7. Nothing anywhere reported anything: no transport connected, no
# power entity, no media source. ON is the "we cannot know" state,
# and the only situation it is allowed to describe.
return MediaPlayerState.ON
@property
@@ -677,9 +733,13 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
if self._jf_active():
features = _BASE_FEATURES | _PLAYBACK_FEATURES
else:
# ADB media session also supports playback controls
adb_info = self._manager.best_session(self.app_id) if self._manager else None
if adb_info and (adb_info.playing or adb_info.paused):
# A media session's playback controls include the BUFFERING case:
# pausing/resuming a buffering stream must be offered exactly like
# PLAYING (t55).
session = (
self._manager.best_session(self.app_id) if self._manager else None
)
if session is not None and self._session_state(session) is not None:
features = _BASE_FEATURES | _PLAYBACK_FEATURES
else:
features = _BASE_FEATURES
@@ -902,6 +962,21 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
np["media_info"] = self._media_info
return np
def _session_playback_label(self, info) -> str:
"""``playing``/``buffering``/``paused``/``idle`` for a live session (t55).
BUFFERING is its own label rather than a flavour of playing: the state
entity says so, and the now-playing dict must not contradict it. The
session flags themselves stay independent; this only names the state.
"""
if info.buffering:
return "buffering"
if info.playing:
return "playing"
if info.paused:
return "paused"
return "idle"
def _get_adb_now_playing(self) -> dict | None:
"""Build now-playing from ADB media session (non-Jellyfin apps)."""
info = self._manager.best_session(self.app_id) if self._manager else None
@@ -909,9 +984,9 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
return None
if not self._adb_store_info:
# Privacy mode: show that something is playing but not what
if info.playing or info.paused:
if info.playing or info.paused or info.buffering:
return {
"state": "playing" if info.playing else "paused",
"state": self._session_playback_label(info),
"title": self.app_name or self.app_id or "Playing",
"source": "media_session",
}
@@ -920,7 +995,7 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
if not info.title:
return None
np = {
"state": "playing" if info.playing else ("paused" if info.paused else "idle"),
"state": self._session_playback_label(info),
"title": info.title,
"source": "media_session",
}
@@ -934,6 +1009,10 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
"""Foreground-tier now-playing (P3-3a): an app is open but publishes
no media session (TiviMate, R2). Title = last known state, else the
last channel entered, else the zero-cost audio-playback hint.
The STATE here is ``idle`` or the last-known session's playback state;
the entity-level ladder reports IDLE for this tier (t55), so the two
agree — a bare foreground app is no longer reported as ON.
"""
manager = self._manager
if manager is None:
@@ -945,8 +1024,8 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
state = "idle"
title = None
info = manager.last_known_session(foreground)
if info is not None and (info.playing or info.paused):
state = "playing" if info.playing else "paused"
if info is not None and (info.playing or info.paused or info.buffering):
state = self._session_playback_label(info)
# Privacy mode: identity stays the app, never the media title
title = None if not self._adb_store_info else info.title
if title is None and not self._adb_store_info:
@@ -963,7 +1042,9 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
# E hint (zero round-trip cost): audio was recently played
title = "Media recently active"
if title is None:
return None # bare foreground app — the state stays ON regardless
# Bare foreground app: no now-playing identity, but the device is
# ON — the entity ladder reports IDLE for this (t55).
return None
return {"state": state, "title": title, "source": "app"}
def _get_power_data(self) -> dict | None: