feat(media_player): source list + native select_source from the app list (t47 A)
The last piece a universal-media-player replacement needs: a dropdown of the apps,
an answer to what is on screen, and a way to pick one — all reading ONE app directory
(the user's Apps map, else DEFAULT_APP_DIRECTORY), so the dropdown can never offer a
name the launch path cannot resolve. That invariant is pinned by a test that walks
source_list and resolves every entry.
* `source_list` — friendly names in directory order (the user's map order, or the
curated list), from a new `_app_directory` accessor that the browse tree now shares.
* `source` — the foreground app's resolved name via the existing `resolve_app_name`
chain (map → cached device-manifest label → raw package), i.e. exactly `app_name`,
so the media player and the Current App sensor cannot disagree.
* `select_source(name)` — a configured select_source script still takes precedence
(t44, unchanged: selecting a source can be more than launching an app); without one
the name is resolved back to its package and launched over the shared
`play_media("app", …)` path, so the native semantics are the ones play_media already
had. Accepts a raw package id (what the browse tree publishes) and matches names
case-insensitively; an unknown or empty name raises
ServiceValidationError(translation_domain="nvidia_shield",
translation_key="unknown_source", translation_placeholders={"source": …}) — a
translated error, never a silent no-op — with new copy in BOTH strings files.
* SELECT_SOURCE is advertised when EITHER a select_source script is configured OR
source_list is non-empty; VOLUME_SET stays script-only (the protocol has no
absolute-volume key), and the two flags are pinned as independent.
Two t44 tests were updated because this is a deliberate behaviour change: "no native
path" is now only true of volume_set, and SELECT_SOURCE is advertised by default
because the curated directory always offers something.
Also: rewriting the strings files re-indented the seven script labels added in t44
(they were valid JSON but sat two levels shallow); the diff there is that fix plus
the new key.
Verified: 441 passed / 2 skipped, ruff clean, drift engine exit 0 under real HA
(23 modules, 59 imports, 157 relative imports, 36 call arguments, 7 awaited HA calls).
This commit is contained in:
1 parent
b3f897c36e
commit
b2aab15ebf
6 files changed
+326
-44
No files matched your search
@@ -672,13 +672,13 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
else:
|
||||
features = _BASE_FEATURES
|
||||
|
||||
# t44: a command that exists ONLY as a script still has to be reachable, and
|
||||
# the feature flag is what makes HA offer it. volume_set and select_source
|
||||
# have no native path at all, so they appear exactly when configured —
|
||||
# without a script the feature set is what it always was.
|
||||
# t44/t47: a command that exists ONLY as a script still has to be
|
||||
# reachable, and the feature flag is what makes HA offer it. volume_set has
|
||||
# no native path at all; select_source appears when EITHER a script is
|
||||
# configured OR there is a source list to offer (t47 gave it a native path).
|
||||
if self._script_for("volume_set"):
|
||||
features |= MediaPlayerEntityFeature.VOLUME_SET
|
||||
if self._script_for("select_source"):
|
||||
if self._script_for("select_source") or self.source_list:
|
||||
features |= MediaPlayerEntityFeature.SELECT_SOURCE
|
||||
return features
|
||||
|
||||
@@ -702,6 +702,34 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
"""The user's apps name/icon map (P4-1 options editor)."""
|
||||
return self._entry.options.get(CONF_APP_NAME_MAP) or {}
|
||||
|
||||
@property
|
||||
def _app_directory(self) -> dict:
|
||||
"""Launchable apps: the user's map, else the curated default (P4-1).
|
||||
|
||||
ONE source for the browse tree, the source list and the reverse lookup, so
|
||||
the dropdown can never offer a name the launch path cannot resolve.
|
||||
"""
|
||||
return self._entry.options.get(CONF_APP_NAME_MAP) or DEFAULT_APP_DIRECTORY
|
||||
|
||||
@property
|
||||
def source_list(self) -> list[str]:
|
||||
"""The apps as friendly names — the dropdown a UMP replacement reads (t47).
|
||||
|
||||
Directory order (the user's map order, or the curated list), so rebuilding
|
||||
the page does not shuffle it.
|
||||
"""
|
||||
return [str(name) for name in self._app_directory.values()]
|
||||
|
||||
@property
|
||||
def source(self) -> str | None:
|
||||
"""The foreground app's friendly name — the same chain as app_name (t47).
|
||||
|
||||
``resolve_app_name``: the user's map, then the cached device-manifest label,
|
||||
then the raw package. Same answer as the Current App sensor, so a
|
||||
universal-media-player replacement cannot see two different truths.
|
||||
"""
|
||||
return self.app_name
|
||||
|
||||
@property
|
||||
def app_name(self):
|
||||
"""P5-1 resolution order — explicit override first:
|
||||
@@ -1029,14 +1057,42 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
"""
|
||||
await self._script_served("volume_set", {SCRIPT_VAR_LEVEL: volume})
|
||||
|
||||
async def async_select_source(self, source):
|
||||
"""Select a source — reachable only with a script (t44).
|
||||
def _source_package(self, source) -> str | None:
|
||||
"""The package behind a source name, or a package id passed through (t47)."""
|
||||
directory = self._app_directory
|
||||
wanted = str(source or "").strip()
|
||||
if not wanted:
|
||||
return None
|
||||
if wanted in directory:
|
||||
# an automation may hand us the package directly; that is what the
|
||||
# browse tree publishes as media_content_id
|
||||
return wanted
|
||||
folded = wanted.casefold()
|
||||
for package, name in directory.items():
|
||||
if str(name).casefold() == folded:
|
||||
return package
|
||||
return None
|
||||
|
||||
The integration has no native source selection (app launching goes through
|
||||
play_media with the app map). A script for this command is the hook for an
|
||||
AVR input or anything else that has to move with the Shield.
|
||||
async def async_select_source(self, source):
|
||||
"""Select an app by its friendly name (t47) — script first, else launch it.
|
||||
|
||||
A configured ``select_source`` script keeps precedence (t44: selecting a
|
||||
source can be more than launching an app — an AVR input, a speaker switch).
|
||||
Without one the name is resolved back to its package through the SAME
|
||||
directory the source list came from and launched over the shared
|
||||
``play_media("app", …)`` path, so a universal-media-player replacement gets
|
||||
the native behaviour. An unknown name is an error, never a silent no-op.
|
||||
"""
|
||||
await self._script_served("select_source", {SCRIPT_VAR_APP_NAME: source})
|
||||
if await self._script_served("select_source", {SCRIPT_VAR_APP_NAME: source}):
|
||||
return
|
||||
package = self._source_package(source)
|
||||
if package is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unknown_source",
|
||||
translation_placeholders={"source": str(source)},
|
||||
)
|
||||
await self.async_play_media(MediaType.APP, package)
|
||||
|
||||
async def _remote_key_cmd(self, remote_key: str, adb_key: str) -> None:
|
||||
"""Simple key command without intent bookkeeping: protocol → ADB keyevent."""
|
||||
@@ -1128,7 +1184,7 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
removed a name media_player has not exported for a long time, which
|
||||
made every real browse raise.
|
||||
"""
|
||||
app_map = self._entry.options.get(CONF_APP_NAME_MAP) or DEFAULT_APP_DIRECTORY
|
||||
app_map = self._app_directory
|
||||
children = [
|
||||
BrowseMedia(
|
||||
title=name,
|
||||
|
||||
@@ -107,13 +107,13 @@
|
||||
"channel_digit_delay": "Channel digit delay (seconds)",
|
||||
"channel_send_enter": "Send ENTER after channel digits",
|
||||
"script_turn_on": "Turn on script",
|
||||
"script_turn_off": "Turn off script",
|
||||
"script_volume_up": "Volume up script",
|
||||
"script_volume_down": "Volume down script",
|
||||
"script_volume_set": "Volume set script",
|
||||
"script_volume_mute": "Mute script",
|
||||
"script_select_source": "Select source script",
|
||||
"logcat_events": "Instant app detection (logcat stream)"
|
||||
"script_turn_off": "Turn off script",
|
||||
"script_volume_up": "Volume up script",
|
||||
"script_volume_down": "Volume down script",
|
||||
"script_volume_set": "Volume set script",
|
||||
"script_volume_mute": "Mute script",
|
||||
"script_select_source": "Select source script",
|
||||
"logcat_events": "Instant app detection (logcat stream)"
|
||||
},
|
||||
"data_description": {
|
||||
"jellyfin_entity": "The Jellyfin media_player for this Shield. Stream details come from the HA Jellyfin integration \u2014 no extra setup needed.",
|
||||
@@ -162,6 +162,10 @@
|
||||
"adb_unavailable": {
|
||||
"title": "ADB unavailable",
|
||||
"message": "The ADB connection of {entity_id} is down \u2014 command not sent."
|
||||
},
|
||||
"unknown_source": {
|
||||
"title": "Unknown source",
|
||||
"message": "'{source}' is not one of this Shield's apps. Use a name from the source list \u2014 the app names from the Apps map, or the built-in list when no map is set."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
|
||||
@@ -107,13 +107,13 @@
|
||||
"channel_digit_delay": "Channel digit delay (seconds)",
|
||||
"channel_send_enter": "Send ENTER after channel digits",
|
||||
"script_turn_on": "Turn on script",
|
||||
"script_turn_off": "Turn off script",
|
||||
"script_volume_up": "Volume up script",
|
||||
"script_volume_down": "Volume down script",
|
||||
"script_volume_set": "Volume set script",
|
||||
"script_volume_mute": "Mute script",
|
||||
"script_select_source": "Select source script",
|
||||
"logcat_events": "Instant app detection (logcat stream)"
|
||||
"script_turn_off": "Turn off script",
|
||||
"script_volume_up": "Volume up script",
|
||||
"script_volume_down": "Volume down script",
|
||||
"script_volume_set": "Volume set script",
|
||||
"script_volume_mute": "Mute script",
|
||||
"script_select_source": "Select source script",
|
||||
"logcat_events": "Instant app detection (logcat stream)"
|
||||
},
|
||||
"data_description": {
|
||||
"jellyfin_entity": "The Jellyfin media_player for this Shield. Stream details come from the HA Jellyfin integration \u2014 no extra setup needed.",
|
||||
@@ -162,6 +162,10 @@
|
||||
"adb_unavailable": {
|
||||
"title": "ADB unavailable",
|
||||
"message": "The ADB connection of {entity_id} is down \u2014 command not sent."
|
||||
},
|
||||
"unknown_source": {
|
||||
"title": "Unknown source",
|
||||
"message": "'{source}' is not one of this Shield's apps. Use a name from the source list \u2014 the app names from the Apps map, or the built-in list when no map is set."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
|
||||
Reference in new issue
Block a user