A media card like a phone's now playing, with the album cover on a Guition (0.2.77)

The card behind a media tile shows the album cover, the title, the artist and
the album, a progress bar that runs while the track plays with the times at its
ends, round keys for previous, play or pause and next, and a volume row with a
mute key, a wide slider and the percentage. Keys the player lacks stay faded,
an off player shows one power key, a stream without a length shows no bar, and
the CYD shows the player's icon in the cover's place. A media tile over the
whole page is the same card under the tile's head. media_card.h holds the
geometry, tests/test_media_card.cpp checks every shape.

The cover comes the way camera images do: the screen asks with the size and
the colour behind the corners, the app fetches the picture from Home Assistant,
cuts it square, rounds the corners over that colour and serves it on port 8098;
the Guition loads it once and asks again only when the picture's mark in the
state changes. Firmware 0.2.64.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-18 13:23:33 +02:00
parent b14086ee9f
commit f13d8ad0ed
27 changed files with 1381 additions and 74 deletions
+53 -4
View File
@@ -392,6 +392,36 @@ class HomeAssistant:
raise ValueError('image too large')
return bytes(raw)
def media_picture(self, entity):
"""The address of a media player's cover as its state carries it right now, or '' without one."""
attrs = self.states.get(entity, {}).get('attributes', {})
picture = attrs.get('entity_picture') or attrs.get('entity_picture_local')
return picture if isinstance(picture, str) and picture else ''
async def media_image(self, entity):
"""The cover of a media player (app 0.2.77), fetched where its state points: Home Assistant's own proxy
with this app's token (`/api/media_player_proxy/...`), or a picture on the internet as it is."""
picture = self.media_picture(entity)
if not picture:
raise ValueError('no picture')
root = self.base[:-4] if self.base.endswith('/api') else self.base
if picture.startswith('/'):
url, headers = f'{root}{picture}', {'Authorization': 'Bearer ' + self.token}
elif picture.startswith(('http://', 'https://')):
url, headers = picture, {}
else:
raise ValueError('unknown picture address')
async with self.session.get(url, headers=headers, timeout=ClientTimeout(total=camera_feed.FETCH_SECONDS)) as response:
response.raise_for_status()
if (response.content_length or 0) > camera_feed.MAX_SNAPSHOT_BYTES:
raise ValueError('picture too large')
raw = bytearray()
async for chunk in response.content.iter_chunked(65536):
raw += chunk
if len(raw) > camera_feed.MAX_SNAPSHOT_BYTES:
raise ValueError('picture too large')
return bytes(raw)
async def fire(self, event_type, data):
"""One Home Assistant event of our own, such as the answer to a tile event."""
await self.request('fire_event', event_type=event_type, event_data=data)
@@ -502,7 +532,9 @@ class Manager:
self.card_histories, self.card_history_fetches = {}, {}
# Camera images (firmware 0.2.57+): the feed behind the camera port, and the cameras of recent alerts
# (entity -> monotonic time) that a screen may open full screen without a tile.
self.camera = camera_feed.CameraFeed(lambda entity: self.ha.camera_image(entity))
self.camera = camera_feed.CameraFeed(lambda entity: self.ha.camera_image(entity),
fetch_cover=lambda entity: self.ha.media_image(entity),
picture=lambda entity: self.ha.media_picture(entity))
self.alert_cameras = {}
if self.path.exists():
raw = json.loads(self.path.read_text())
@@ -1357,14 +1389,31 @@ class Manager:
inbox = self.aliases.get(request.get('inbox'), request.get('inbox'))
entity = request.get('entity')
screen = self.screen(inbox) if isinstance(inbox, str) else None
if not camera_feed.supported(entity) or not camera_feed.can_show(screen) or not screen.get('online'):
# A media player's cover (app 0.2.77, firmware 0.2.64+) comes the same way, at the size the card asks for.
cover = camera_feed.cover_request(request) if camera_feed.cover_supported(entity) else None
if cover:
if not camera_feed.can_show_cover(screen) or not screen.get('online'):
return
elif not camera_feed.supported(entity) or not camera_feed.can_show(screen) or not screen.get('online'):
return
action = self.transport(inbox, screen)
if not action or not self.camera_allowed(inbox, entity):
return
message = await self.camera_message(entity, 'full', screen['board'])
message = await self.cover_message(entity, *cover) if cover else await self.camera_message(entity, 'full', screen['board'])
await self.ha.send(inbox, message, action)
LOG.info('Camera %s on %s%s', entity, screen['name'], '' if message['u'] else ': no image')
LOG.info('%s %s on %s%s', 'Cover of' if cover else 'Camera', entity, screen['name'], '' if message['u'] else ': no image')
async def cover_message(self, entity, size, background):
"""The screen message with a link to a media player's cover at `size` with `background` behind the corners,
or an empty link when the player shows no picture (the card keeps its placeholder)."""
url = ''
base = await camera_feed.base_url(self.ha.request)
if base:
token = self.camera.link(entity, (size, size), cover=(size, background)) if await self.camera.cover(entity, size, background) else None
url = f'{base}/camera/{token}.bmp' if token else ''
else:
LOG.warning('Covers: no address for this app on the LAN; set SCREEN_CAMERA_URL')
return {'v': 1, 'op': 'camera', 't': 'cover', 'e': entity, 'u': url}
async def alert_images(self, camera, screens):
"""The picture of an alert's camera at that moment, on every screen that draws it."""