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:
1 parent
07682ff082
commit
ccde3cff71
2 files changed
+605
-43
No files matched your search
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user