feat(media-player): a switch Receiver is ensured on, and apps leave the browser (t61)

Two halves of the same live-run report.

RECEIVER AS A SWITCH. The user: "it would be nice if a receiver could just be a
switch, I don't need my volume scripts then because the receiver portion could
just make sure the receiver is on if it's a switch". `_ensure_receiver_on()`
runs at the top of the three playback entry points — turn_on, play_media,
select_source — and calls switch.turn_on only when the configured switch
receiver reports `off`: an `on` switch gets no call (idempotent), an
unavailable/unknown/missing state is left alone with a debug line rather than
guessed at, and a failing service call is logged, never raised — a receiver that
cannot be switched on must not stop the Shield from playing. Nothing else about
the switch is touched (no volume, no toggle), and nothing switches it from a
state read, a poll or a listener, so an off receiver stays off while the Shield
is idle. A media_player receiver keeps its behaviour exactly. select_source now
dispatches through the private `_dispatch_play_media`, so one user action looks
at the same switch once; `play_media` keeps its public signature and behaviour.
The Receiver field already accepted switch.* at the validation/strings layer
(t56, pinned by the real-HA tests); the strings copy now says what playback does
with it.

MEDIA BROWSER. The user: "Media Browser shows Apps. To me it should probably be
integrated with the home assistant media display, keep select sources for apps".
`async_browse_media` no longer publishes the app directory: it returns a valid,
standard BrowseMedia root (device title, DIRECTORY class, no content id, no
children) instead of a second, inconsistent copy of the list. Apps stay reachable
through source_list/source/select_source and play_media("app", <package>), which
is unchanged and complete; nothing else on this entity is browsable, so there is
no dead entry to select, and the drift gate's t37 D1 browse assertions still hold.

Tests: tests/test_receiver_switch.py pins all four promised states — off →
exactly one switch.turn_on; on → none; unavailable/unknown/missing/failing →
playback continues and nothing raises; media_player receiver → never switched —
plus the idle-Shield rule. The browse pins move from the removed tree to the
surviving surfaces (source_list, _source_package, _app_directory), and the harness
gains the real hass.states shape (FakeStates) and the real switch state constants.
README documents both changes; both strings files stay byte-identical.
This commit is contained in:
engineer committed 2026-10-12 08:34:53 +01:00
1 parent c884c7e1ab
commit 431b7cd8f4
8 files changed
+448 -133

No files matched your search

+23 -8
View File
@@ -352,8 +352,15 @@ list). A malformed row — no `=`, or an empty name — is rejected with an erro
page rather than being half-saved.
A receiver may be a **`switch.*`** as well as a `media_player.*`: a soundbar or
speaker that only knows on and off is a valid receiver. (Driving that switch during
playback is a separate, later change.)
speaker that only knows on and off is a valid receiver. Playback then **ensures
that switch is on**: `turn_on`, `play_media` and `select_source` call
`switch.turn_on` when — and only when — it reports `off`, and touch nothing else
about it (never volume, never a toggle). An already-on switch gets no call at all,
a switch whose state cannot be read (`unavailable`/`unknown`) is left alone rather
than guessed at, and a receiver that is off while the Shield is idle stays off.
That is what makes receiver **volume scripts unnecessary** for this shape: there is
no volume to script, only an on/off state to ensure. A `media_player.*` receiver
keeps its own behaviour — input selection and attribute enrichment.
- **Jellyfin Media Player** — auto-detected or manually selected
- **Receiver + Input** — for media-zone coordination; `media_player` or `switch`
@@ -497,13 +504,22 @@ sweep could not run), while the Configure row forces one.
### Sources (the app list in a dropdown)
The media player's source list is your **Apps** map — the same map the Browse tree and
app-name resolution use. With no map configured it falls back to a small built-in list
The media player's source list is your **Apps** map — the same map app-name resolution
uses. With no map configured it falls back to a small built-in list
(TiviMate, Wholphin, Netflix, YouTube), so a universal-media-player card always has
something to show. Discovery fills the **Configure** table, not this fallback: the
dropdown stays exactly the map you saved, so what a card offers is always what you
chose. An app that discovery found and you want in the dropdown is one row to add.
**Apps are sources, not browser media.** This integration no longer publishes the app
directory through HA's media **Browser**: the Browse dialog shows an **empty tree** for
this entity (the device name, no rows) instead of a second, inconsistent copy of the
same list — the user report was *"Media Browser shows Apps. To me it should probably be
integrated with the home assistant media display, keep select sources for apps"*.
Everything an app row could do lives in `source_list` and `media_player.select_source`
below, and nothing else on this entity is browsable, so there is no dead entry to click
and nothing else the browser could show.
```yaml
# a media-player card, or anything that reads source_list
type: media-control
@@ -516,7 +532,7 @@ entity: media_player.living_room_shield
sensor: your map first, then the cached device label, then the raw package.
- **`media_player.select_source`** — picks one **by name** and launches it over the
protocol, the same path `play_media` uses for apps. A raw package id works too (that
is what the Browse tree publishes), names match case-insensitively, and a name that
holds the package id), names match case-insensitively, and a name that
is not in the list is an error rather than a silent no-op:
```yaml
@@ -537,10 +553,9 @@ The map is your own override, so two shapes are accepted rather than papered ove
- **Names differing only by case** (`com.a=Dup`, `com.b=dup`) offer **one** dropdown
row — the first spelling — because name matching is case-insensitive. The shadowed
app is not lost: the Browse tree still lists it under its own package id, and
`media_player.select_source` accepts that id.
app is not lost: `media_player.select_source` accepts its own package id.
- A **padded value** typed by hand (`com.x= Netflix `) is shown cleaned in the
dropdown and the Browse tree, but `app_name`, `source` and the Current App sensor
dropdown, but `app_name`, `source` and the Current App sensor
display the map value **verbatim** — so `source` can be a string that is not in
`source_list`. A blank value falls through to the raw package instead.