From f13d8ad0edf8c6288515c587f25b6bb13cd34433 Mon Sep 17 00:00:00 2001
From: Max Gramser
Date: Fri, 18 Sep 2026 13:23:33 +0200
Subject: [PATCH] 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
---
README.md | 14 +-
components/smart_display/camera_view.h | 15 +-
components/smart_display/media_card.h | 156 +++++++
components/smart_display/runtime_model.h | 8 +-
components/smart_display/runtime_tiles.h | 494 ++++++++++++++++++++---
components/smart_display/theme.h | 3 +
components/smart_display/tile_controls.h | 2 +-
docs/CAMERA.md | 17 +
docs/TEST_RESULTS_0277.md | 85 ++++
docs/images/cyd-dark-media.png | Bin 0 -> 11789 bytes
docs/images/cyd-media-full.png | Bin 0 -> 13483 bytes
docs/images/cyd-media.png | Bin 0 -> 11725 bytes
docs/images/guition-dark-media.png | Bin 0 -> 27846 bytes
docs/images/guition-media-full.png | Bin 0 -> 37824 bytes
docs/images/guition-media.png | Bin 0 -> 27604 bytes
guition-4848s040.yaml | 2 +-
home-like-2432s028.yaml | 2 +-
packages/cyd.yaml | 2 +-
packages/guition.yaml | 2 +-
screen_manager/CHANGELOG.md | 11 +
screen_manager/app/camera_feed.py | 142 ++++++-
screen_manager/app/core.py | 36 +-
screen_manager/app/server.py | 57 ++-
screen_manager/config.yaml | 2 +-
tests/test_camera_view.cpp | 34 ++
tests/test_media_card.cpp | 103 +++++
tests/test_media_card.py | 268 ++++++++++++
27 files changed, 1381 insertions(+), 74 deletions(-)
create mode 100644 components/smart_display/media_card.h
create mode 100644 docs/TEST_RESULTS_0277.md
create mode 100644 docs/images/cyd-dark-media.png
create mode 100644 docs/images/cyd-media-full.png
create mode 100644 docs/images/cyd-media.png
create mode 100644 docs/images/guition-dark-media.png
create mode 100644 docs/images/guition-media-full.png
create mode 100644 docs/images/guition-media.png
create mode 100644 tests/test_media_card.cpp
create mode 100644 tests/test_media_card.py
diff --git a/README.md b/README.md
index e1b756f..e12f638 100644
--- a/README.md
+++ b/README.md
@@ -51,7 +51,12 @@ also be changed on the screen or by an automation, and your own YAML for one scr
-Tap a tile to switch it, hold it for the full card. Keys and sliders right on the tile, pastel colors, a clock, the weather and history you can read with a finger. Rendered from the firmware's own LVGL code with a demo home.
+
+
+
+
+
+Tap a tile to switch it, hold it for the full card. Keys and sliders right on the tile, pastel colors, a clock, the weather and history you can read with a finger. The media card shows what plays with its album cover, fetched by ESP Screens like a camera picture (how). Rendered from the firmware's own LVGL code with a demo home.
@@ -62,7 +67,12 @@ also be changed on the screen or by an automation, and your own YAML for one scr
-The same cards on the 2.8-inch CYD, 320 × 240.
+
+
+
+
+
+The same cards on the 2.8-inch CYD, 320 × 240. The CYD has no memory for pictures: its media card shows the player's icon in the cover's place.
diff --git a/components/smart_display/camera_view.h b/components/smart_display/camera_view.h
index 0f76390..0dfe7ca 100644
--- a/components/smart_display/camera_view.h
+++ b/components/smart_display/camera_view.h
@@ -21,11 +21,18 @@ struct Feed {
std::string entity, url;
uint32_t asked_at = 0, started_at = 0, finished_at = 0;
bool asked = false, loading = false, shown = false, empty = false; // empty: the app said it has no image
+ // once (firmware 0.2.64+): an album cover on the media card. It loads one time per link and stays; a new cover comes
+ // with a new link (the card asks again when Home Assistant's picture changes), never on a clock.
+ bool once = false, loaded = false; // loaded: this link's image is on screen
uint8_t failures = 0;
- void open(const std::string &camera) { *this = Feed{}; entity = camera; }
+ void open(const std::string &camera, bool one_load = false) { *this = Feed{}; entity = camera; once = one_load; }
bool open() const { return !entity.empty(); }
- bool should_ask(uint32_t now) const { return open() && !loading && url.empty() && (!asked || now - asked_at >= ASK_AGAIN_MS); }
+ // A cover the app has none of (or an app that knows no covers) is asked for once: the card keeps its placeholder.
+ bool should_ask(uint32_t now) const {
+ if (once && empty) return false;
+ return open() && !loading && url.empty() && (!asked || now - asked_at >= ASK_AGAIN_MS);
+ }
void ask(uint32_t now) { asked = true; asked_at = now; }
// The app's answer: a link, or none (no image from Home Assistant, or a camera this screen may not show).
void link(const std::string &address) {
@@ -33,17 +40,19 @@ struct Feed {
empty = address.empty();
failures = 0;
started_at = finished_at = 0;
+ loaded = false;
}
bool should_load(uint32_t now) const {
if (!open() || loading || url.empty()) return false;
if (!started_at) return true;
+ if (once) return !loaded && now - finished_at >= GAP_MS; // a failed load is tried again, a loaded one stays
return now - started_at >= REFRESH_MS && now - finished_at >= GAP_MS;
}
void start(uint32_t now) { loading = true; started_at = now ? now : 1; }
void finish(uint32_t now, bool ok) {
loading = false;
finished_at = now;
- if (ok) { shown = true; failures = 0; return; }
+ if (ok) { shown = true; loaded = true; failures = 0; return; }
if (++failures >= MAX_FAILURES) { url.clear(); asked = false; failures = 0; }
}
};
diff --git a/components/smart_display/media_card.h b/components/smart_display/media_card.h
new file mode 100644
index 0000000..1ae72e2
--- /dev/null
+++ b/components/smart_display/media_card.h
@@ -0,0 +1,156 @@
+#pragma once
+// The media card (firmware 0.2.64+): where its parts go and what its texts say. Pure arithmetic, free of LVGL and
+// ESPHome, so tests/test_media_card.cpp checks every shape on a PC; runtime_tiles.h draws it.
+//
+// The card is a "now playing" view like a phone's: the album art (or a placeholder with the player's icon), the title,
+// the artist and the album, a progress bar with the elapsed and total time, three round keys (previous, play or pause,
+// next) and a volume row (a mute key, a slider, the percentage). Two forms share one recipe: a tall area (the Guition's
+// card, 480 wide under its top bar) stacks everything under the art; a wide area (the CYD's card, a tile over the whole
+// page) puts the art at the left with the texts, the bar and the keys beside it. The volume row always runs along the
+// bottom. What does not fit goes: first the artist line, then the times beside the bar, and the art shrinks last.
+#include
+#include
+#include
+#include
+
+namespace media_card {
+struct Rect {
+ int x = 0, y = 0, w = 0, h = 0;
+ int cx() const { return x + w / 2; }
+ int cy() const { return y + h / 2; }
+ int right() const { return x + w; }
+ int bottom() const { return y + h; }
+};
+// What the board brings: its size class and the line heights of the fonts the card writes with.
+struct Metrics {
+ bool large = true; // the Guition: big keys and a big cover; the CYD gets the small numbers
+ int title_h = 32; // the title's font (the card heading font)
+ int artist_h = 25; // the artist line (the control font)
+ int small_h = 19; // the times and the percentage (the small font)
+ int key_h() const { return large ? 52 : 34; } // previous and next
+ int play_h() const { return large ? 64 : 40; } // play or pause, the biggest key: a thumb finds it without looking
+ int key_gap() const { return large ? 28 : 14; } // between the keys, less when the row has no room
+ int min_gap() const { return large ? 12 : 8; }
+ int mute_h() const { return large ? 32 : 22; }
+ int slider_h() const { return large ? 20 : 12; }
+ int bar_h() const { return large ? 6 : 4; }
+ int gap() const { return large ? 12 : 6; }
+ int margin() const { return large ? 24 : 10; }
+ int max_art() const { return large ? 200 : 120; }
+ int percent_w() const { return large ? 52 : 34; }
+ int time_w() const { return large ? 48 : 34; } // "12:34" beside the bar
+};
+struct Layout {
+ bool wide = false; // the art at the left, everything else beside it
+ bool times = false; // the elapsed and total time at the ends of the bar
+ bool artist = true; // the artist line (a tile over a CYD page has no room for it)
+ Rect art, title, artist_line, bar, elapsed, total, prev, play, next, mute, volume, percent;
+ int art_radius = 0; // the corner the art is rounded with (baked into the picture by the app)
+};
+
+inline int radius_for(int art) { return std::max(4, art / 12); }
+
+// The card's parts inside an area of `width` × `height` whose top left is (0, 0): the room under the top bar of a
+// card, or under the head of a tile over the whole page.
+inline Layout layout(const Metrics &m, int width, int height) {
+ Layout l;
+ const int g = m.gap(), margin = m.margin();
+ // The volume row along the bottom, whatever the form.
+ const int volume_h = std::max(m.mute_h(), m.slider_h());
+ l.mute = {margin, height - volume_h + (volume_h - m.mute_h()) / 2, m.mute_h(), m.mute_h()};
+ l.percent = {width - margin - m.percent_w(), height - volume_h + (volume_h - m.small_h) / 2, m.percent_w(), m.small_h};
+ const int slider_x = l.mute.right() + g;
+ l.volume = {slider_x, height - volume_h + (volume_h - m.slider_h()) / 2, std::max(1, l.percent.x - g - slider_x), m.slider_h()};
+ const int above = height - volume_h - g; // room for the rest
+ l.wide = width * 4 > height * 5;
+ const int row_min = 2 * m.key_h() + m.play_h() + 2 * m.min_gap();
+ // The bar's row: the elapsed time at the left, the total at the right, the bar between them, on one small line.
+ auto bar_row = [&](int x, int w, int y, bool with_times) {
+ const int tw = with_times && w >= 4 * m.time_w() ? m.time_w() : 0; // no room for times on a very narrow row
+ l.times = tw > 0;
+ const int row_h = l.times ? m.small_h : m.bar_h();
+ l.elapsed = {x, y, tw, m.small_h};
+ l.total = {x + w - tw, y, tw, m.small_h};
+ const int bar_x = x + (tw ? tw + g / 2 : 0), bar_w = std::max(1, w - 2 * (tw ? tw + g / 2 : 0));
+ l.bar = {bar_x, y + (row_h - m.bar_h()) / 2, bar_w, m.bar_h()};
+ return row_h;
+ };
+ auto keys = [&](int x, int w, int y) {
+ const int gap = std::clamp((w - 2 * m.key_h() - m.play_h()) / 2, m.min_gap(), m.key_gap());
+ const int row_w = 2 * m.key_h() + m.play_h() + 2 * gap, row_x = x + (w - row_w) / 2;
+ l.prev = {row_x, y + (m.play_h() - m.key_h()) / 2, m.key_h(), m.key_h()};
+ l.play = {row_x + m.key_h() + gap, y, m.play_h(), m.play_h()};
+ l.next = {l.play.right() + gap, l.prev.y, m.key_h(), m.key_h()};
+ };
+ if (!l.wide) {
+ // Tall: the art on top, the texts, the bar and the keys under it, all centred.
+ const int keys_y = above - m.play_h();
+ const int stack = m.title_h + m.artist_h + g + m.small_h + g; // between the art and the keys: the bar row is one small line
+ int art = std::min(m.max_art(), keys_y - g - stack - g);
+ art = std::max(32, art);
+ const int stack_y = keys_y - g - stack, text_w = width - 2 * margin;
+ l.art = {(width - art) / 2, std::max(0, (stack_y - g - art) / 2), art, art};
+ l.title = {margin, stack_y, text_w, m.title_h};
+ l.artist_line = {margin, l.title.bottom(), text_w, m.artist_h};
+ bar_row(margin, text_w, l.artist_line.bottom() + g, true);
+ keys(0, width, keys_y);
+ } else {
+ // Wide: the art at the left, a column beside it, centred on the art and never taller than the room; the column's
+ // own gaps are half the card's, so a tile's head leaves room for the artist line.
+ int art = std::min({m.max_art(), above, width - 2 * margin - 2 * g - row_min});
+ art = std::max(32, art);
+ const int h = g / 2;
+ int column = m.title_h + m.artist_h + h + m.small_h + h + m.play_h();
+ l.artist = column <= above;
+ if (!l.artist) column -= m.artist_h;
+ bool with_times = column <= above;
+ if (!with_times) column -= m.small_h - m.bar_h();
+ l.art = {margin, std::max(0, (above - art) / 2), art, art};
+ const int x = l.art.right() + 2 * g, w = std::max(1, width - margin - x);
+ int y = std::clamp(l.art.y + (art - column) / 2, 0, std::max(0, above - column));
+ l.title = {x, y, w, m.title_h}; y += m.title_h;
+ if (l.artist) { l.artist_line = {x, y, w, m.artist_h}; y += m.artist_h; }
+ y += h;
+ y += bar_row(x, w, y, with_times);
+ keys(x, w, y + h);
+ }
+ l.art_radius = radius_for(l.art.w);
+ return l;
+}
+
+// Home Assistant reports where the track was (media_position) and when it said so (media_position_updated_at);
+// while playing the position runs on from that moment.
+inline uint32_t elapsed_seconds(uint32_t position, uint32_t position_at, uint32_t now, bool playing, uint32_t duration) {
+ uint32_t elapsed = position;
+ if (playing && position_at && now > position_at) elapsed += now - position_at;
+ if (duration && elapsed > duration) elapsed = duration;
+ return elapsed;
+}
+// Per mille of the track, or -1 without a duration.
+inline int progress(uint32_t position, uint32_t position_at, uint32_t now, bool playing, uint32_t duration) {
+ if (!duration) return -1;
+ return static_cast(elapsed_seconds(position, position_at, now, playing, duration) * 1000ULL / duration);
+}
+// "3:07", "1:02:45": the way every player writes a time.
+inline std::string clock_text(uint32_t seconds) {
+ char b[24];
+ if (seconds >= 3600) snprintf(b, sizeof(b), "%u:%02u:%02u", (unsigned) (seconds / 3600), (unsigned) (seconds / 60 % 60), (unsigned) (seconds % 60));
+ else snprintf(b, sizeof(b), "%u:%02u", (unsigned) (seconds / 60), (unsigned) (seconds % 60));
+ return b;
+}
+// The second line: "Artist · Album", or whichever of the two there is.
+inline std::string subtitle(const std::string &artist, const std::string &album) {
+ if (!artist.empty() && !album.empty()) return artist + " · " + album;
+ return artist.empty() ? album : artist;
+}
+// What the card says instead of a title when nothing plays.
+inline const char *idle_text(const std::string &state) {
+ if (state == "off") return "Off";
+ if (state == "standby") return "Standby";
+ if (state == "buffering") return "Loading";
+ if (state == "unavailable") return "Unavailable";
+ return "Not playing";
+}
+inline bool playing(const std::string &state) { return state == "playing" || state == "buffering"; }
+inline bool has_track(const std::string &state) { return state == "playing" || state == "paused" || state == "buffering"; }
+} // namespace media_card
diff --git a/components/smart_display/runtime_model.h b/components/smart_display/runtime_model.h
index 0f475b7..808e0be 100644
--- a/components/smart_display/runtime_model.h
+++ b/components/smart_display/runtime_model.h
@@ -95,6 +95,11 @@ struct Extra {
std::string sunrise, sunset, duration, remaining;
uint32_t timer_end = 0;
std::string media_title;
+ // The media card (firmware 0.2.64+, app 0.2.77+): the artist and the album, the track's length and where it was when
+ // Home Assistant last said so (seconds, and that moment as an epoch), and a short mark of the cover picture, empty
+ // when the player shows none. The mark changes with the picture: the card fetches a new cover when it does.
+ std::string media_artist, media_album, media_picture;
+ uint32_t media_duration = 0, media_position = 0, media_position_at = 0;
// Vacuum: its own speeds (at most four) and speed, the mode, water and suction rows (see Choice), and
// from sensors of its device the room it is in and whether it charges.
std::vector fan_speeds;
@@ -115,7 +120,8 @@ struct Extra {
return hvac_modes.empty() && fan_modes.empty() && swing_modes.empty() && fan_mode.empty() && swing_mode.empty() &&
hvac_action.empty() && options.empty() && forecast.empty() && hours.empty() && std::isnan(wind) &&
std::isnan(feels) && wind_unit.empty() && sunrise.empty() && sunset.empty() && duration.empty() &&
- remaining.empty() && !timer_end && media_title.empty() && fan_speeds.empty() && fan_speed.empty() &&
+ remaining.empty() && !timer_end && media_title.empty() && media_artist.empty() && media_album.empty() &&
+ media_picture.empty() && !media_duration && !media_position && !media_position_at && fan_speeds.empty() && fan_speed.empty() &&
choices.empty() && room.empty() && !charging && std::isnan(tilt) && action.empty() && action_data.empty() &&
action_templates.empty() && state_word.empty();
}
diff --git a/components/smart_display/runtime_tiles.h b/components/smart_display/runtime_tiles.h
index 221d464..b19b6e3 100644
--- a/components/smart_display/runtime_tiles.h
+++ b/components/smart_display/runtime_tiles.h
@@ -12,6 +12,7 @@
#include "tile_controls.h"
#include "history_view.h"
#include "camera_view.h"
+#include "media_card.h"
#include "swipe_profile.h"
#include "esphome/components/json/json_util.h"
#include "esphome/components/api/api_server.h"
@@ -66,6 +67,10 @@ inline bool awake() { return !screen_awake || screen_awake(); }
inline uint32_t now_epoch() { if (!now_time) return 0; auto t = now_time(); return t.is_valid() ? static_cast(t.timestamp) : 0; }
inline void tick();
inline void refresh_tile(size_t index);
+// Style setters that only touch a property when it changes (defined with the card renderers below).
+inline void set_color(lv_obj_t *obj, lv_style_prop_t prop, lv_color_t color, lv_style_selector_t selector = 0);
+inline void set_number(lv_obj_t *obj, lv_style_prop_t prop, int32_t number, lv_style_selector_t selector = 0);
+inline void set_font(lv_obj_t *obj, const lv_font_t *value);
#ifdef SWIPE_PROFILE
inline void swipe_test(unsigned count, unsigned interval_ms, unsigned back_ms, JsonObject tuning);
#endif
@@ -81,6 +86,15 @@ inline void history_received();
inline void camera_open(const std::string &entity, const std::string &name);
inline void camera_answer(const std::string &view, const std::string &entity, const std::string &url);
inline bool camera_supported();
+// The media card's album cover (firmware 0.2.64+): the card or a tile over the whole page says which cover it shows,
+// the board's online_image loads it; see the end of this file.
+enum class CoverOwner : uint8_t { NONE, DETAIL, TILE };
+inline void cover_want(const std::string &entity, const std::string &picture, int size, uint32_t background, CoverOwner owner, size_t slot);
+inline lv_image_dsc_t *cover_ready(const std::string &entity, int size, uint32_t background);
+// The card's cover on screen, and the part a media tile keeps its cover in; both go with the image buffer.
+inline lv_obj_t *media_detail_picture = nullptr;
+constexpr unsigned MEDIA_PICTURE = 14;
+inline void media_action(Tile &t, int cmd);
inline uint32_t domain_accent(const Tile &t);
inline const char *icon_for(const Tile &tile);
inline void label(lv_obj_t *obj, const std::string &text);
@@ -183,6 +197,9 @@ struct Widgets {
lv_obj_t *extra{}; std::string extra_mode; bool extra_full=false; std::array parts{}; lv_point_precise_t *points{};
// Analog clock: centre and radius of the dial, so the second hand can move without a card redraw.
int hand_cx=0, hand_cy=0, hand_r=0, hand_width=1;
+ // A media tile over the whole page (firmware 0.2.64+): the width of its progress bar, so the fill can run once a
+ // second without a card redraw.
+ int media_bar_w=0;
// Soft area under a polyline (graph, sun path), painted by the extra container's draw event.
const lv_point_precise_t *fill_points{}; unsigned fill_count=0; int fill_x=0, fill_y=0, fill_base=0; lv_color_t fill_color{}; lv_opa_t fill_opa=0;
// Direct controls on a wide card: a panel at the right with pill keys, a -/+ pill,
@@ -362,9 +379,10 @@ inline std::string receive(const std::string &payload) {
}
if (op == "camera") {
// A link to a camera's image (app 0.2.66+): the answer to camera_request ("full"), or an alert's image ("alert",
- // announced with an empty link before show_alert and sent again with the link). Only ESP Screens' own port.
+ // announced with an empty link before show_alert and sent again with the link), or the media card's cover
+ // ("cover", app 0.2.77+). Only ESP Screens' own port.
const std::string view = string(root["t"], 8), entity = string(root["e"], 120), url = string(root["u"], 240);
- if (!valid_entity(entity) || (view != "full" && view != "alert")) return false;
+ if (!valid_entity(entity) || (view != "full" && view != "alert" && view != "cover")) return false;
if (!url.empty() && url.rfind("http://", 0) != 0) return false;
camera_answer(view, entity, url);
result = model.ready() ? "Synced" : "Loading tiles";
@@ -548,6 +566,12 @@ inline std::string receive(const std::string &payload) {
tile.muted=a["is_volume_muted"].is() && a["is_volume_muted"].as();
tile.device_class=string(a["device_class"],24);next.hvac_action=string(a["hvac_action"],24);
next.media_title=string(a["media_title"],80);tile.supported=a["supported_features"].as();
+ // The media card (firmware 0.2.64+, app 0.2.77+): the artist, the album, the track's length and position, and a
+ // mark of the cover picture; an app from before sends none of them and the card shows what it has.
+ next.media_artist=string(extra["artist"],80);next.media_album=string(extra["album"],80);next.media_picture=string(extra["pic"],16);
+ next.media_duration=extra["dur"].is()?extra["dur"].as():0;
+ next.media_position=extra["pos"].is()?extra["pos"].as():0;
+ next.media_position_at=extra["at"].is()?extra["at"].as():0;
tile.name = string(root["name"], 80);
tile.state = string(root["state"], 160);
tile.unit = string(a["unit_of_measurement"], 20);
@@ -860,7 +884,8 @@ inline void slider_event(lv_event_t *e){
if(code==LV_EVENT_PRESS_LOST && captured_slider==slider){captured_slider=nullptr;slider_changed=false;}
if(code==LV_EVENT_RELEASED && captured_slider==slider){
unsigned index=(uintptr_t)lv_event_get_user_data(e);
- for(auto &w:widgets)if(w.slider==slider || w.control_slider==slider){index=w.index;break;}
+ // A slider among a card's own parts (the media tile's volume) belongs to the tile the slot shows now.
+ for(auto &w:widgets)if(w.slider==slider || w.control_slider==slider || (w.extra && lv_obj_get_parent(slider)==w.extra)){index=w.index;break;}
bool changed=slider_changed;captured_slider=nullptr;slider_changed=false;
if(changed && cyd::touch_guard.accept_slider(esphome::millis(),200+index))commit_slider(index,lv_slider_get_value(slider));
}
@@ -876,37 +901,39 @@ inline void choose(Tile &t,Choice &row,const std::string &value){
row.sent=value;t.begin(esphome::millis());
lv_async_call([](void *){if(detail_root && !lv_obj_has_flag(detail_root,LV_OBJ_FLAG_HIDDEN))show_detail(detail_index);},nullptr);
}
+// What a key on a card does; `cmd` is the key's command. Shared by the plain keys (detail_button) and the round
+// keys of the media card. -1 is Back.
+inline void detail_command(int cmd){
+ if(cmd==-1){hide_detail();return;}
+ // History ranges (firmware 0.2.51+): redraw after this event, which belongs to a key the redraw deletes.
+ if(cmd>=160&&cmd<163){
+ static const uint32_t hours[]={1,24,168};
+ if(detail_index=model.count || !allowed(esphome::millis(),300+cmd,"card button "+model.tiles[detail_index].entity))return;
+ auto &t=model.tiles[detail_index];if(!t.available()||t.waiting(esphome::millis()))return;
+ if(cmd<4){const char *services[]={"vacuum.start","vacuum.pause","vacuum.return_to_base","vacuum.locate"};action(services[cmd],t.entity);}
+ // Vacuum rows: 10-15 suction, 50-55 cleaning mode, 60-65 water.
+ static const std::pair rows[]={{10,'s'},{50,'m'},{60,'w'}};
+ for(const auto &[first,kind]:rows)
+ if(cmd>=first && cmdvalues.size())choose(t,*row,row->values[cmd-first]);}
+ // The media card's keys (20-23); its volume slider goes through commit_slider.
+ if(cmd>=20 && cmd<=24)media_action(t,cmd);
+ if(cmd>=30 && cmd<38 && cmd-30<(int)t.extra().options.size())action(t.domain()+".select_option",t.entity,"option",t.extra().options[cmd-30]);
+ // Cover keys: 70 + tile_controls::Command (open, stop, close and the tilt keys).
+ if(cmd>=70 && cmd<130){auto a=tile_controls::key_action(t,cmd-70);if(a.valid()&&t.domain()=="cover")action(a.service,t.entity,a.key,a.value);}
+ if(cmd==40)action(t.state=="active"?"timer.pause":"timer.start",t.entity);
+ if(cmd==41)action("timer.cancel",t.entity);
+}
inline lv_obj_t *detail_button(const char *text,int x,int y,int width,int height,int command){
auto *button=lv_obj_create(detail_root);lv_obj_remove_style_all(button);lv_obj_set_pos(button,x,y);lv_obj_set_size(button,width,height);
lv_obj_set_style_bg_color(button,theme::color(command==0?theme::ACCENT:theme::BUTTON),0);lv_obj_set_style_bg_opa(button,LV_OPA_COVER,0);lv_obj_set_style_radius(button,12,0);lv_obj_add_flag(button,LV_OBJ_FLAG_CLICKABLE);
auto *label=detail_label(button,text,6,0,width-12);lv_obj_center(label);lv_obj_set_style_text_align(label,LV_TEXT_ALIGN_CENTER,0);if(command==0)lv_obj_set_style_text_color(label,theme::color(theme::ON_ACCENT),0);lv_obj_remove_flag(label,LV_OBJ_FLAG_CLICKABLE);
- lv_obj_add_event_cb(button,[](lv_event_t *e){
- int cmd=(intptr_t)lv_event_get_user_data(e);if(cmd==-1){hide_detail();return;}
- // History ranges (firmware 0.2.51+): redraw after this event, which belongs to a key the redraw deletes.
- if(cmd>=160&&cmd<163){
- static const uint32_t hours[]={1,24,168};
- if(detail_index=model.count || !allowed(esphome::millis(),300+cmd,"card button "+model.tiles[detail_index].entity))return;
- auto &t=model.tiles[detail_index];if(!t.available()||t.waiting(esphome::millis()))return;
- if(cmd<4){const char *services[]={"vacuum.start","vacuum.pause","vacuum.return_to_base","vacuum.locate"};action(services[cmd],t.entity);}
- // Vacuum rows: 10-15 suction, 50-55 cleaning mode, 60-65 water.
- static const std::pair rows[]={{10,'s'},{50,'m'},{60,'w'}};
- for(const auto &[first,kind]:rows)
- if(cmd>=first && cmdvalues.size())choose(t,*row,row->values[cmd-first]);}
- if(cmd==20)action("media_player.media_play_pause",t.entity);
- if(cmd==21)action("media_player.media_previous_track",t.entity);
- if(cmd==22)action("media_player.media_next_track",t.entity);
- if(cmd>=30 && cmd<38 && cmd-30<(int)t.extra().options.size())action(t.domain()+".select_option",t.entity,"option",t.extra().options[cmd-30]);
- // Cover keys: 70 + tile_controls::Command (open, stop, close and the tilt keys).
- if(cmd>=70 && cmd<130){auto a=tile_controls::key_action(t,cmd-70);if(a.valid()&&t.domain()=="cover")action(a.service,t.entity,a.key,a.value);}
- if(cmd==40)action(t.state=="active"?"timer.pause":"timer.start",t.entity);
- if(cmd==41)action("timer.cancel",t.entity);
- },LV_EVENT_SHORT_CLICKED,(void*)(intptr_t)command);
+ lv_obj_add_event_cb(button,[](lv_event_t *e){detail_command((intptr_t)lv_event_get_user_data(e));},LV_EVENT_SHORT_CLICKED,(void*)(intptr_t)command);
lv_obj_set_style_bg_color(button,theme::color(theme::ACCENT_PRESSED),LV_STATE_PRESSED);
lv_obj_set_style_transform_width(button,-2,LV_STATE_PRESSED);lv_obj_set_style_transform_height(button,-2,LV_STATE_PRESSED);
lv_obj_set_style_opa(button,LV_OPA_50,LV_STATE_DISABLED);
@@ -1730,6 +1757,172 @@ inline void render_history_detail(const Tile &t,bool large,int width,int height,
}
history_ranges(pad,range_y,width-2*pad,range_h);
}
+// ---- The media card (firmware 0.2.64+) ----
+// "Now playing" as a phone shows it: the album cover (app 0.2.77+ serves it, a Guition draws it; the CYD keeps the
+// placeholder), the title, the artist and the album, a progress bar that runs while the track plays, round keys for
+// previous, play or pause and next, and the volume row. media_card.h decides where everything goes for a tall card and
+// a wide one; the same parts draw the card here and a tile over the whole page (render_media_full). The keys go
+// through detail_command like every card's, the slider through commit_slider.
+inline lv_obj_t *media_progress_fill=nullptr,*media_elapsed_label=nullptr; // the card's, moved by tick() once a second
+inline int media_bar_width=0;
+inline media_card::Rect media_art_rect; // where the card's cover goes once it is here
+inline uint32_t media_accent(){return theme::foreground(theme::ha::LIGHT_BLUE);}
+inline const lv_font_t *tile_icon_font(){for(auto &w:widgets)if(w.icon_font)return w.icon_font;return mini_icon_font?mini_icon_font:detail_font;}
+inline media_card::Metrics media_metrics(bool large){
+ media_card::Metrics m;m.large=large;
+ const lv_font_t *title=watch_font?watch_font:detail_font,*artist=control_font?control_font:detail_font,*small=small_font?small_font:detail_font;
+ m.title_h=lv_font_get_line_height(title);m.artist_h=lv_font_get_line_height(artist);m.small_h=lv_font_get_line_height(small);
+ return m;
+}
+// What the keys do, on the card and on a tile over the whole page: 20 play or pause, 21 previous, 22 next, 23 mute.
+inline void media_action(Tile &t,int cmd){
+ if(cmd==20)action("media_player.media_play_pause",t.entity);
+ if(cmd==21)action("media_player.media_previous_track",t.entity);
+ if(cmd==22)action("media_player.media_next_track",t.entity);
+ if(cmd==23)action("media_player.volume_mute",t.entity,"is_volume_muted",t.muted?"false":"true");
+ if(cmd==24)action("media_player.turn_on",t.entity);
+}
+// An off or standby player shows one key: power, when the player can be turned on from here.
+inline bool media_off(const Tile &t){return t.state=="off" || t.state=="standby";}
+inline std::string media_volume_text(const Tile &t){
+ if(t.muted)return "Muted";
+ if(!std::isfinite(t.volume))return "";
+ return std::to_string((int)std::lround(std::clamp(t.volume,0.0f,1.0f)*100))+" %";
+}
+// A rounded box without a style of its own: the art's placeholder, the bar's track and its fill.
+inline lv_obj_t *media_box(lv_obj_t *parent,lv_obj_t *existing,const media_card::Rect &r,uint32_t color,int radius){
+ auto *o=existing;
+ if(!o){o=lv_obj_create(parent);lv_obj_remove_style_all(o);lv_obj_set_style_bg_opa(o,LV_OPA_COVER,0);lv_obj_remove_flag(o,LV_OBJ_FLAG_CLICKABLE);lv_obj_remove_flag(o,LV_OBJ_FLAG_SCROLLABLE);}
+ lv_obj_set_pos(o,r.x,r.y);lv_obj_set_size(o,std::max(1,r.w),std::max(1,r.h));
+ set_color(o,LV_STYLE_BG_COLOR,theme::rgb(color));set_number(o,LV_STYLE_RADIUS,radius);
+ return o;
+}
+// A round key: a glyph on a grey circle, the play key on the accent, the mute key bare beside the slider. Faded while
+// it cannot be used. `existing` keeps a tile's key across redraws; the card builds its keys anew each time.
+inline lv_obj_t *media_key(lv_obj_t *parent,lv_obj_t *existing,const media_card::Rect &r,const char *glyph,const lv_font_t *font,bool accent,bool bare,bool enabled,lv_event_cb_t cb,void *user){
+ auto *key=existing;
+ if(!key){
+ key=lv_obj_create(parent);lv_obj_remove_style_all(key);
+ lv_obj_set_style_radius(key,LV_RADIUS_CIRCLE,0);lv_obj_set_style_bg_opa(key,bare?LV_OPA_TRANSP:LV_OPA_COVER,0);lv_obj_set_style_bg_opa(key,LV_OPA_COVER,LV_STATE_PRESSED);
+ lv_obj_set_style_opa(key,LV_OPA_40,LV_STATE_DISABLED);
+ lv_obj_add_flag(key,LV_OBJ_FLAG_CLICKABLE);lv_obj_remove_flag(key,LV_OBJ_FLAG_SCROLLABLE);
+ lv_obj_set_ext_click_area(key,bare?10:6);
+ auto *icon=lv_label_create(key);lv_obj_remove_flag(icon,LV_OBJ_FLAG_CLICKABLE);
+ lv_obj_add_event_cb(key,cb,LV_EVENT_SHORT_CLICKED,user);
+ }
+ lv_obj_set_pos(key,r.x,r.y);lv_obj_set_size(key,r.w,r.h);
+ set_color(key,LV_STYLE_BG_COLOR,theme::color(accent?theme::ACCENT:theme::KEY));
+ set_color(key,LV_STYLE_BG_COLOR,theme::color(accent?theme::ACCENT_PRESSED:theme::KEY_PRESSED),LV_STATE_PRESSED);
+ auto *icon=lv_obj_get_child(key,0);
+ if(font)set_font(icon,font);
+ set_color(icon,LV_STYLE_TEXT_COLOR,theme::color(accent?theme::ON_ACCENT:theme::INK));
+ label(icon,glyph);lv_obj_center(icon);
+ if(enabled)lv_obj_remove_state(key,LV_STATE_DISABLED);else lv_obj_add_state(key,LV_STATE_DISABLED);
+ return key;
+}
+// The volume slider: the media colour on a pale track, a round white knob, the range every card slider has.
+inline lv_obj_t *media_slider(lv_obj_t *parent,lv_obj_t *existing,const media_card::Rect &r,const Tile &t,bool large,bool enabled,void *user){
+ auto *s=existing;
+ if(!s){
+ s=lv_slider_create(parent);lv_obj_remove_style_all(s);lv_slider_set_range(s,0,1000);
+ lv_obj_set_style_bg_opa(s,LV_OPA_COVER,LV_PART_MAIN);lv_obj_set_style_bg_opa(s,LV_OPA_COVER,LV_PART_INDICATOR);lv_obj_set_style_bg_opa(s,LV_OPA_COVER,LV_PART_KNOB);
+ lv_obj_set_style_radius(s,LV_RADIUS_CIRCLE,LV_PART_MAIN);lv_obj_set_style_radius(s,LV_RADIUS_CIRCLE,LV_PART_INDICATOR);lv_obj_set_style_radius(s,LV_RADIUS_CIRCLE,LV_PART_KNOB);
+ lv_obj_set_style_pad_all(s,large?4:3,LV_PART_KNOB);lv_obj_set_style_bg_opa(s,LV_OPA_80,(lv_style_selector_t)LV_PART_KNOB|(lv_style_selector_t)LV_STATE_PRESSED);
+ lv_obj_set_style_opa(s,LV_OPA_40,LV_STATE_DISABLED);
+ lv_obj_set_ext_click_area(s,large?12:8);
+ lv_obj_add_event_cb(s,slider_event,LV_EVENT_ALL,user);
+ }
+ lv_obj_set_pos(s,r.x,r.y);lv_obj_set_size(s,std::max(1,r.w),r.h);
+ set_color(s,LV_STYLE_BG_COLOR,theme::rgb(theme::tint(theme::ha::LIGHT_BLUE,64)),LV_PART_MAIN);
+ set_color(s,LV_STYLE_BG_COLOR,theme::rgb(t.muted?theme::hex(theme::OFF):media_accent()),LV_PART_INDICATOR);
+ set_color(s,LV_STYLE_BG_COLOR,theme::color(theme::SLIDER_KNOB),LV_PART_KNOB);
+ if(!lv_obj_has_state(s,LV_STATE_PRESSED) && lv_slider_get_value(s)!=slider_value(t))lv_slider_set_value(s,slider_value(t),LV_ANIM_OFF);
+ if(enabled){lv_obj_remove_state(s,LV_STATE_DISABLED);lv_obj_add_flag(s,LV_OBJ_FLAG_CLICKABLE);}
+ else{lv_obj_add_state(s,LV_STATE_DISABLED);lv_obj_remove_flag(s,LV_OBJ_FLAG_CLICKABLE);}
+ return s;
+}
+// The cover over its placeholder: LVGL's image widget exists only on a board whose profile draws images.
+inline lv_obj_t *media_picture_show(lv_obj_t *parent,lv_obj_t *existing,const media_card::Rect &r,lv_image_dsc_t *src){
+#if LV_USE_IMAGE
+ if(!src || !src->data)return existing;
+ auto *p=existing;
+ if(!p){p=lv_image_create(parent);lv_obj_remove_flag(p,LV_OBJ_FLAG_CLICKABLE);}
+ lv_image_set_src(p,src);lv_obj_set_pos(p,r.x,r.y);lv_obj_set_size(p,r.w,r.h);lv_obj_invalidate(p);
+ return p;
+#else
+ (void)parent;(void)r;(void)src;return existing;
+#endif
+}
+// The bar's fill and the elapsed time follow the track: once a second from tick(), without a redraw.
+inline void media_progress(const Tile &t,lv_obj_t *fill,lv_obj_t *elapsed,int bar_w){
+ const auto &x=t.extra();
+ if(!fill || !x.media_duration)return;
+ const bool play=media_card::playing(t.state);
+ const int p=media_card::progress(x.media_position,x.media_position_at,now_epoch(),play,x.media_duration);
+ const int h=lv_obj_get_style_height(fill,LV_PART_MAIN);
+ const int w=std::max(h,bar_w*std::max(0,p)/1000);
+ if(lv_obj_get_style_width(fill,LV_PART_MAIN)!=w)lv_obj_set_width(fill,w);
+ if(elapsed)label(elapsed,media_card::clock_text(media_card::elapsed_seconds(x.media_position,x.media_position_at,now_epoch(),play,x.media_duration)));
+}
+// The card: everything under the top bar, from `top` down.
+inline void render_media_detail(Tile &t,unsigned index,bool large,int width,int height,int top){
+ using namespace media_card;
+ using namespace tile_controls;
+ const auto &x=t.extra();
+ const Metrics m=media_metrics(large);
+ const Layout l=layout(m,width,std::max(60,height-top-(large?12:6)));
+ auto at=[&](Rect r){r.y+=top;return r;};
+ const bool usable=fresh()&&t.available(),track=usable&&has_track(t.state),play=media_card::playing(t.state);
+ const uint32_t f=t.supported;auto can=[&](uint32_t bit){return usable&&(!f||(f&bit));};
+ // The cover, or its placeholder with the player's icon; the cover comes over it once the app served it.
+ auto *frame=media_box(detail_root,nullptr,at(l.art),theme::tint(theme::ha::LIGHT_BLUE,51),l.art_radius);
+ const std::string glyph=icon_for(t);
+ const lv_font_t *placeholder_font=big_icon_font&&font_has(big_icon_font,glyph)?big_icon_font:tile_icon_font();
+ auto *icon=lv_label_create(frame);lv_obj_remove_flag(icon,LV_OBJ_FLAG_CLICKABLE);lv_obj_set_style_text_font(icon,placeholder_font,0);
+ lv_obj_set_style_text_color(icon,theme::rgb(theme::icon(theme::ha::LIGHT_BLUE)),0);lv_label_set_text(icon,glyph.c_str());lv_obj_center(icon);
+ media_art_rect=at(l.art);media_detail_picture=nullptr;
+ const uint32_t ground=theme::hex(theme::PAGE);
+ if(camera_supported()&&track&&!x.media_picture.empty()){
+ cover_want(t.entity,x.media_picture,l.art.w,ground,CoverOwner::DETAIL,0);
+ media_detail_picture=media_picture_show(detail_root,nullptr,media_art_rect,cover_ready(t.entity,l.art.w,ground));
+ }
+ // Title, artist · album.
+ const lv_font_t *title_font=watch_font?watch_font:detail_font,*artist_font=control_font?control_font:detail_font,*small=small_font?small_font:detail_font;
+ const lv_text_align_t align=l.wide?LV_TEXT_ALIGN_LEFT:LV_TEXT_ALIGN_CENTER;
+ detail_text(detail_root,track&&!x.media_title.empty()?x.media_title:std::string(idle_text(usable?t.state:"unavailable")),l.title.x,l.title.y+top,l.title.w,title_font,align,theme::INK);
+ if(l.artist)detail_text(detail_root,track?subtitle(x.media_artist,x.media_album):std::string(),l.artist_line.x,l.artist_line.y+top,l.artist_line.w,artist_font,align,theme::MUTED);
+ // The progress bar: the fill runs while the track plays; a stream without a length has no bar to show.
+ media_progress_fill=nullptr;media_elapsed_label=nullptr;media_bar_width=l.bar.w;
+ if(track && x.media_duration){
+ media_box(detail_root,nullptr,at(l.bar),theme::hex(theme::TRACK),LV_RADIUS_CIRCLE);
+ Rect fill=at(l.bar);fill.w=std::max(l.bar.h,l.bar.w*std::max(0,progress(x.media_position,x.media_position_at,now_epoch(),play,x.media_duration))/1000);
+ media_progress_fill=media_box(detail_root,nullptr,fill,media_accent(),LV_RADIUS_CIRCLE);
+ if(l.times){
+ media_elapsed_label=detail_text(detail_root,clock_text(elapsed_seconds(x.media_position,x.media_position_at,now_epoch(),play,x.media_duration)),l.elapsed.x,l.elapsed.y+top,l.elapsed.w,small,LV_TEXT_ALIGN_LEFT,theme::SUBTLE);
+ detail_text(detail_root,clock_text(x.media_duration),l.total.x,l.total.y+top,l.total.w,small,LV_TEXT_ALIGN_RIGHT,theme::SUBTLE);
+ }
+ }
+ // The keys: previous, play or pause on the accent, next; the mute key bare at the start of the volume row. An off
+ // player shows one power key instead, and no volume row: it reports no volume.
+ auto cb=[](lv_event_t *e){detail_command((intptr_t)lv_event_get_user_data(e));};
+ const lv_font_t *key_font=mini_icon_font?mini_icon_font:detail_font;
+ std::vector keys;
+ if(usable && media_off(t)){
+ if(can(feature::MEDIA_TURN_ON))keys.push_back(media_key(detail_root,nullptr,at(l.play),glyph::POWER,tile_icon_font(),true,false,true,cb,(void*)(intptr_t)24));
+ }else{
+ keys={media_key(detail_root,nullptr,at(l.prev),glyph::PREVIOUS,key_font,false,false,can(feature::MEDIA_PREVIOUS),cb,(void*)(intptr_t)21),
+ media_key(detail_root,nullptr,at(l.play),play?glyph::PAUSE:glyph::PLAY,tile_icon_font(),true,false,can(feature::MEDIA_PLAY|feature::MEDIA_PAUSE),cb,(void*)(intptr_t)20),
+ media_key(detail_root,nullptr,at(l.next),glyph::NEXT,key_font,false,false,can(feature::MEDIA_NEXT),cb,(void*)(intptr_t)22)};
+ if(std::isfinite(t.volume)){
+ keys.push_back(media_key(detail_root,nullptr,at(l.mute),t.muted?glyph::MUTED:glyph::VOLUME,key_font,false,true,can(feature::MEDIA_VOLUME_MUTE),cb,(void*)(intptr_t)23));
+ media_slider(detail_root,nullptr,at(l.volume),t,large,can(feature::MEDIA_VOLUME_SET),(void*)(uintptr_t)index);
+ detail_text(detail_root,media_volume_text(t),l.percent.x,l.percent.y+top,l.percent.w,small,LV_TEXT_ALIGN_RIGHT,theme::MUTED);
+ }
+ }
+ // Only the keys the player supports join the card's actions: tick() enables those again after a wait, and a key
+ // the player lacks stays faded.
+ for(auto *k:keys)if(!lv_obj_has_state(k,LV_STATE_DISABLED) && detail_action_count<32)detail_actions[detail_action_count++]=k;
+}
inline void show_detail(unsigned index){
if(index>=model.count)return;
// A card that opens starts on a day (an hour for a tile whose graph shows one); switching ranges keeps it open.
@@ -1739,7 +1932,8 @@ inline void show_detail(unsigned index){
detail_index=index;auto &t=model.tiles[index];
if(!detail_font)detail_font=lv_obj_get_style_text_font(widgets[0].title,LV_PART_MAIN);
if(!detail_root){detail_root=lv_obj_create(lv_screen_active());lv_obj_remove_style_all(detail_root);lv_obj_set_size(detail_root,lv_pct(100),lv_pct(100));lv_obj_remove_flag(detail_root,LV_OBJ_FLAG_SCROLLABLE);}
- detail_action_count=0;detail_status=nullptr;detail_badge_status=nullptr;detail_switch=nullptr;history_forget();lv_obj_clean(detail_root);lv_obj_remove_flag(detail_root,LV_OBJ_FLAG_HIDDEN);lv_obj_move_foreground(detail_root);
+ detail_action_count=0;detail_status=nullptr;detail_badge_status=nullptr;detail_switch=nullptr;history_forget();
+ media_progress_fill=nullptr;media_elapsed_label=nullptr;media_detail_picture=nullptr;lv_obj_clean(detail_root);lv_obj_remove_flag(detail_root,LV_OBJ_FLAG_HIDDEN);lv_obj_move_foreground(detail_root);
lv_obj_set_style_bg_color(detail_root,theme::color(theme::PAGE),0);lv_obj_set_style_bg_opa(detail_root,LV_OPA_COVER,0);
int width=lv_display_get_horizontal_resolution(lv_display_get_default()), height=lv_display_get_vertical_resolution(lv_display_get_default());
bool large=width>=480;int pad=large?20:10, top=large?100:62, gap=large?12:6,bh=large?58:34,cw=(width-pad*2-gap)/2;
@@ -1754,7 +1948,7 @@ inline void show_detail(unsigned index){
std::string state=d=="cover"?cover_status_line(t):detail_state(t);
// The vacuum and history cards draw their own state.
const bool with_history=history_card(t);
- if(d!="vacuum"&&!with_history){detail_status=detail_label(detail_root,state+(t.unit.empty()?"":" "+t.unit),pad,large?80:50,width-2*pad);lv_obj_set_style_text_align(detail_status,LV_TEXT_ALIGN_CENTER,0);lv_obj_set_style_text_color(detail_status,theme::color(theme::MUTED),0);}
+ if(d!="vacuum"&&d!="media_player"&&!with_history){detail_status=detail_label(detail_root,state+(t.unit.empty()?"":" "+t.unit),pad,large?80:50,width-2*pad);lv_obj_set_style_text_align(detail_status,LV_TEXT_ALIGN_CENTER,0);lv_obj_set_style_text_color(detail_status,theme::color(theme::MUTED),0);}
if(with_history){
render_history_detail(t,large,width,height,pad);
}else if(d=="vacuum"){
@@ -1766,12 +1960,8 @@ inline void show_detail(unsigned index){
const auto &options=t.extra().options;
for(unsigned i=0;i=widgets.size() || n>3)return;
+ auto &w=widgets[slot];
+ if(!enabled || !fresh() || w.index>=model.count || w.extra_mode!="media")return;
+ if(lv_obj_has_state(lv_event_get_target_obj(e),LV_STATE_DISABLED))return;
+ const uint32_t now=esphome::millis();
+ if(!allowed(now,500+slot*16+n,"media key "+std::to_string(slot)))return;
+ auto &t=model.tiles[w.index];
+ if(!t.available() || t.waiting(now))return;
+ static const int commands[]={21,20,22,23};
+ media_action(t,n==1 && media_off(t)?24:commands[n]);
+}
+inline void render_media_full(Widgets &w,const Tile &t,bool big,int content_w,int content_h,int head_h){
+ using namespace media_card;
+ using namespace tile_controls;
+ const auto &x=t.extra();
+ const Metrics m=media_metrics(big);
+ const int top=head_h+(big?8:4);
+ const Layout l=layout(m,content_w,std::max(40,content_h-top-(big?4:2)));
+ begin_extra(w,"media",content_w,content_h);
+ auto at=[&](Rect r){r.y+=top;return r;};
+ const bool usable=fresh()&&t.available(),track=usable&&has_track(t.state),play=media_card::playing(t.state);
+ const uint32_t f=t.supported;auto can=[&](uint32_t bit){return usable&&(!f||(f&bit));};
+ const size_t slot=&w-widgets.data();
+ // The placeholder and the player's icon; the cover comes over them once the app served it.
+ w.parts[0]=media_box(w.extra,w.parts[0],at(l.art),theme::tint(theme::ha::LIGHT_BLUE,51),l.art_radius);
+ const std::string glyph=icon_for(t);
+ const lv_font_t *placeholder_font=big_icon_font&&font_has(big_icon_font,glyph)?big_icon_font:w.icon_font;
+ if(!w.parts[1]){w.parts[1]=lv_label_create(w.parts[0]);lv_obj_remove_flag(w.parts[1],LV_OBJ_FLAG_CLICKABLE);}
+ set_font(w.parts[1],placeholder_font);set_color(w.parts[1],LV_STYLE_TEXT_COLOR,theme::rgb(theme::icon(theme::ha::LIGHT_BLUE)));label(w.parts[1],glyph);lv_obj_center(w.parts[1]);
+ const uint32_t ground=theme::of(lv_obj_get_style_bg_color(w.tile,LV_PART_MAIN));
+ lv_image_dsc_t *src=nullptr;
+ // A card open over the page owns the cover then; the tile asks again once the card closes (cover_tick).
+ const bool card_open=detail_root && !lv_obj_has_flag(detail_root,LV_OBJ_FLAG_HIDDEN);
+ if(camera_supported()&&track&&!x.media_picture.empty()&&!card_open){cover_want(t.entity,x.media_picture,l.art.w,ground,CoverOwner::TILE,slot);src=cover_ready(t.entity,l.art.w,ground);}
+ if(src)w.parts[MEDIA_PICTURE]=media_picture_show(w.extra,w.parts[MEDIA_PICTURE],at(l.art),src);
+ else if(w.parts[MEDIA_PICTURE]){lv_obj_delete(w.parts[MEDIA_PICTURE]);w.parts[MEDIA_PICTURE]=nullptr;}
+ // Title, artist · album, left-aligned beside the cover.
+ const lv_font_t *title_font=watch_font?watch_font:w.title_font,*artist_font=control_font?control_font:w.title_font,*small=small_font?small_font:w.title_font;
+ auto text=[&](unsigned i,const lv_font_t *font,const Rect &r,lv_text_align_t align,const std::string &value,theme::Role role){
+ auto *p=part_label(w,i,font,r.x,r.y+top,r.w,align,value);lv_label_set_long_mode(p,LV_LABEL_LONG_DOT);set_color(p,LV_STYLE_TEXT_COLOR,theme::color(role));lv_obj_remove_flag(p,LV_OBJ_FLAG_HIDDEN);return p;
+ };
+ text(2,title_font,l.title,LV_TEXT_ALIGN_LEFT,track&&!x.media_title.empty()?x.media_title:std::string(idle_text(usable?t.state:"unavailable")),theme::INK);
+ if(l.artist)text(3,artist_font,l.artist_line,LV_TEXT_ALIGN_LEFT,track?subtitle(x.media_artist,x.media_album):std::string(),theme::MUTED);
+ else if(w.parts[3])lv_obj_add_flag(w.parts[3],LV_OBJ_FLAG_HIDDEN);
+ // The progress bar and its times; a stream without a length has none.
+ w.media_bar_w=l.bar.w;
+ const bool timed=track&&x.media_duration;
+ if(timed){
+ w.parts[4]=media_box(w.extra,w.parts[4],at(l.bar),theme::hex(theme::TRACK),LV_RADIUS_CIRCLE);lv_obj_remove_flag(w.parts[4],LV_OBJ_FLAG_HIDDEN);
+ Rect fill=at(l.bar);fill.w=std::max(l.bar.h,l.bar.w*std::max(0,progress(x.media_position,x.media_position_at,now_epoch(),play,x.media_duration))/1000);
+ w.parts[5]=media_box(w.extra,w.parts[5],fill,media_accent(),LV_RADIUS_CIRCLE);lv_obj_remove_flag(w.parts[5],LV_OBJ_FLAG_HIDDEN);
+ }else for(unsigned i:{4u,5u})if(w.parts[i])lv_obj_add_flag(w.parts[i],LV_OBJ_FLAG_HIDDEN);
+ if(timed&&l.times){
+ text(6,small,l.elapsed,LV_TEXT_ALIGN_LEFT,clock_text(elapsed_seconds(x.media_position,x.media_position_at,now_epoch(),play,x.media_duration)),theme::SUBTLE);
+ text(7,small,l.total,LV_TEXT_ALIGN_RIGHT,clock_text(x.media_duration),theme::SUBTLE);
+ }else for(unsigned i:{6u,7u})if(w.parts[i])lv_obj_add_flag(w.parts[i],LV_OBJ_FLAG_HIDDEN);
+ // The keys and the volume row. Their events carry the slot: the tile in it may change with the page. An off player
+ // shows one power key and no volume row.
+ const lv_font_t *key_font=mini_icon_font?mini_icon_font:w.icon_font;
+ auto user=[&](unsigned n){return (void*)(uintptr_t)(slot*16+n);};
+ auto show=[&](unsigned i,bool on){if(w.parts[i]){if(on)lv_obj_remove_flag(w.parts[i],LV_OBJ_FLAG_HIDDEN);else lv_obj_add_flag(w.parts[i],LV_OBJ_FLAG_HIDDEN);}};
+ const bool off=usable && media_off(t), volume=!off && std::isfinite(t.volume);
+ if(off){
+ w.parts[9]=media_key(w.extra,w.parts[9],at(l.play),glyph::POWER,w.icon_font,true,false,can(feature::MEDIA_TURN_ON),media_tile_key_event,user(1));
+ show(9,can(feature::MEDIA_TURN_ON));
+ }else{
+ w.parts[8]=media_key(w.extra,w.parts[8],at(l.prev),glyph::PREVIOUS,key_font,false,false,can(feature::MEDIA_PREVIOUS),media_tile_key_event,user(0));
+ w.parts[9]=media_key(w.extra,w.parts[9],at(l.play),play?glyph::PAUSE:glyph::PLAY,w.icon_font,true,false,can(feature::MEDIA_PLAY|feature::MEDIA_PAUSE),media_tile_key_event,user(1));
+ w.parts[10]=media_key(w.extra,w.parts[10],at(l.next),glyph::NEXT,key_font,false,false,can(feature::MEDIA_NEXT),media_tile_key_event,user(2));
+ show(9,true);
+ }
+ show(8,!off);show(10,!off);
+ if(volume){
+ w.parts[11]=media_key(w.extra,w.parts[11],at(l.mute),t.muted?glyph::MUTED:glyph::VOLUME,key_font,false,true,can(feature::MEDIA_VOLUME_MUTE),media_tile_key_event,user(3));
+ w.parts[12]=media_slider(w.extra,w.parts[12],at(l.volume),t,big,can(feature::MEDIA_VOLUME_SET),(void*)(uintptr_t)w.index);
+ text(13,small,l.percent,LV_TEXT_ALIGN_RIGHT,media_volume_text(t),theme::MUTED);
+ }
+ show(11,volume);show(12,volume);show(13,volume);
+}
inline void render_full(Widgets &w,const Tile &t,bool custom,bool clock,bool sunpath,bool graph,bool mini,bool with_panel,bool large,
const std::string &value,const std::string &unit,int content_w,int content_h) {
const bool big=w.base_height>80; // the board: a Guition card is large, a CYD card small
@@ -2610,7 +2885,9 @@ inline void render_full(Widgets &w,const Tile &t,bool custom,bool clock,bool sun
}
int value_h=lv_font_get_line_height(lv_obj_get_style_text_font(w.value,LV_PART_MAIN));
int gap=big?12:6;
- if(!mini && !with_panel && !graph){
+ // A media player that answers gets the media card under its head (firmware 0.2.64+); unavailable, it is the plain card.
+ const bool media=t.domain()=="media_player" && fresh() && t.available();
+ if(!mini && !with_panel && !graph && !media){
hide_extra(w);hide_panel(w);lv_obj_add_flag(w.slider,LV_OBJ_FLAG_HIDDEN);
const lv_font_t *name_font=room_label?lv_obj_get_style_text_font(room_label,LV_PART_MAIN):w.title_font;
int name_h=lv_font_get_line_height(name_font),circle=big?128:64;
@@ -2638,6 +2915,11 @@ inline void render_full(Widgets &w,const Tile &t,bool custom,bool clock,bool sun
lv_obj_set_pos(w.title,w.title_x,big?w.title_y:text_y);
lv_obj_set_pos(w.value,w.value_x,big?w.value_y:text_y+title_h+line_gap);
lv_obj_set_width(w.title,std::max(1,content_w-w.title_x));lv_obj_set_width(w.value,std::max(1,content_w-w.value_x));
+ if(media){
+ hide_panel(w);lv_obj_add_flag(w.slider,LV_OBJ_FLAG_HIDDEN);
+ render_media_full(w,t,big,content_w,content_h,head_h);
+ return;
+ }
if(mini){
// The small slider becomes a strip a thumb finds at the bottom; the room above it is the button.
hide_extra(w);hide_panel(w);
@@ -2705,6 +2987,8 @@ inline void render_slot(size_t slot) {
else if (value == "docked") value = "Docked";
// Home Assistant's word where the screen has none of its own (firmware 0.2.58+): a cover says Open, a washer Rinsing.
else if (!t.extra().state_word.empty()) value = t.extra().state_word;
+ // A player's state in the screen's own words where Home Assistant sent none (firmware 0.2.64+).
+ else if (d == "media_player") value = tile_controls::media_state_text(t.state);
else if (!t.unit.empty() && !watch) value += " " + t.unit;
bool pending=t.loading(esphome::millis());
if(d=="weather" && std::isfinite(t.current)) {char b[32];snprintf(b,sizeof(b),"%.1f %s",t.current,t.unit.c_str());value=b;if(watch){snprintf(b,sizeof(b),"%.1f",t.current);value=b;}}
@@ -2851,7 +3135,9 @@ inline void render_slot(size_t slot) {
// The sun path sets its own colours on every render, the sunlit area under its arc too: taking the
// accent here made that area orange after a palette change and yellow again after the next minute.
if(w.extra_mode!="sunpath")w.fill_color=color;
- for(unsigned i=0;i=2 && i%3==2 : w.extra_mode=="sunpath" ? i>=1 : w.extra_mode=="calendar" ? i==15||i==17 : i==16;
if(lv_obj_check_type(p,&lv_label_class))set_color(p,LV_STYLE_TEXT_COLOR,muted?value_color:title_color);
@@ -3411,6 +3697,9 @@ inline void tick() {
uint32_t second=esphome::millis()/1000;
if(second!=last_live_second){
last_live_second=second;
+ // The media card's bar runs on while the track plays (firmware 0.2.64+).
+ if(media_progress_fill && detail_root && !lv_obj_has_flag(detail_root,LV_OBJ_FLAG_HIDDEN) && detail_index=model.count || lv_obj_has_flag(w.tile,LV_OBJ_FLAG_HIDDEN))continue;
const auto &t=model.tiles[w.index];
if((t.is_clock() && new_minute) || (t.domain()=="timer" && t.state=="active") || (t.domain()=="sun" && second%60==0))card(w.index);
+ // A media tile over the whole page: its bar runs on while the track plays (firmware 0.2.64+).
+ if(w.extra_mode=="media" && w.extra && !lv_obj_has_flag(w.extra,LV_OBJ_FLAG_HIDDEN) && w.parts[5] && !lv_obj_has_flag(w.parts[5],LV_OBJ_FLAG_HIDDEN))media_progress(t,w.parts[5],w.parts[6],w.media_bar_w);
// The second hand moves on its own: only its line is redrawn, and it hides during standby. Only while the
// slot's parts are a dial: right after a page switch the slot already names the clock while its parts still
// belong to the card drawn before (a forecast's hour labels take part 18 too), until the fill draws the dial.
@@ -3473,15 +3764,102 @@ constexpr uint8_t ALERT_IMAGE_RETRIES = 3; // an alert's picture is worth anoth
inline bool camera_supported() { return static_cast(camera_full.load); }
inline bool camera_visible() { return camera_root != nullptr; }
+// ---- The album cover of the media card (firmware 0.2.64+) ----
+// The card, or a media tile over the whole page, says which cover it wants: the player, the mark of its picture, the
+// size and the colour behind the rounded corners (cover_want). The app answers `esphome.screen_camera` with a link to
+// a BMP of exactly that (op "camera", t "cover"); the board's full online_image loads it once and the picture stays
+// until the mark changes, the owner goes (the card closes, the page turns) or a camera opens full screen: the camera
+// and the cover share that one image buffer, and the camera wins. Everything runs from camera_tick().
+struct CoverWish { std::string entity, picture; int size = 0; uint32_t background = 0; CoverOwner owner = CoverOwner::NONE; size_t slot = 0; };
+inline CoverWish cover_wish;
+inline camera_view::Feed cover;
+inline void camera_release();
+inline void camera_request(const std::string &entity, int size = 0, uint32_t background = 0);
+// The pictures on screen go before their buffer does.
+inline void cover_forget_pictures() {
+ if (media_detail_picture) { lv_obj_delete(media_detail_picture); media_detail_picture = nullptr; }
+ for (auto &w : widgets) if (w.extra_mode == "media" && w.parts[MEDIA_PICTURE]) { lv_obj_delete(w.parts[MEDIA_PICTURE]); w.parts[MEDIA_PICTURE] = nullptr; }
+}
+inline void cover_release() {
+ const bool had = cover.open();
+ cover = camera_view::Feed{};
+ cover_forget_pictures();
+ if (had && !camera_root) camera_release_due = true;
+}
+inline void cover_want(const std::string &entity, const std::string &picture, int size, uint32_t background, CoverOwner owner, size_t slot) {
+ if (!camera_supported()) return;
+ const bool same = cover_wish.entity == entity && cover_wish.picture == picture && cover_wish.size == size && cover_wish.background == background;
+ cover_wish = CoverWish{entity, picture, size, background, owner, slot};
+ if (same) return;
+ cover_release(); // another cover: asked for on the next tick
+}
+inline lv_image_dsc_t *cover_ready(const std::string &entity, int size, uint32_t background) {
+ if (!cover.loaded || cover.entity != entity || cover_wish.size != size || cover_wish.background != background) return nullptr;
+ auto *src = camera_full.source();
+ return src && src->data ? src : nullptr;
+}
+inline void cover_drop() { cover_wish = CoverWish{}; cover_release(); }
+// Whether the owner still shows the cover: the card open on that player, or the tile on screen in its slot.
+inline bool cover_visible() {
+ if (cover_wish.owner == CoverOwner::DETAIL)
+ return detail_root && !lv_obj_has_flag(detail_root, LV_OBJ_FLAG_HIDDEN) && detail_index < model.count && model.tiles[detail_index].entity == cover_wish.entity;
+ if (cover_wish.owner == CoverOwner::TILE && cover_wish.slot < widgets.size()) {
+ if (detail_root && !lv_obj_has_flag(detail_root, LV_OBJ_FLAG_HIDDEN)) return false; // a card covers the page
+ auto &w = widgets[cover_wish.slot];
+ return w.tile && !lv_obj_has_flag(w.tile, LV_OBJ_FLAG_HIDDEN) && w.index < model.count && model.tiles[w.index].entity == cover_wish.entity && w.extra_mode == "media";
+ }
+ return false;
+}
+// Nobody holds the cover and a media tile with a picture is on the page (the card over it just closed, or the
+// page turned): the tile draws itself again and asks.
+inline void cover_offer() {
+ if (detail_root && !lv_obj_has_flag(detail_root, LV_OBJ_FLAG_HIDDEN)) return;
+ for (auto &w : widgets) {
+ if (!w.tile || lv_obj_has_flag(w.tile, LV_OBJ_FLAG_HIDDEN) || w.index >= model.count || w.extra_mode != "media") continue;
+ const auto &t = model.tiles[w.index];
+ if (t.extra().media_picture.empty() || !media_card::has_track(t.state)) continue;
+ refresh_tile(w.index);
+ return;
+ }
+}
+// The cover is here: onto the card at once, or the tile draws itself again with it.
+inline void cover_arrived() {
+ if (!cover_visible()) return;
+ if (cover_wish.owner == CoverOwner::DETAIL) media_detail_picture = media_picture_show(detail_root, media_detail_picture, media_art_rect, camera_full.source());
+ else refresh_tile(widgets[cover_wish.slot].index);
+ ESP_LOGI("camera", "cover of %s shown", cover.entity.c_str());
+}
+inline void cover_tick(uint32_t now) {
+ if (camera_root || !camera_supported()) return;
+ if (cover_wish.owner == CoverOwner::NONE) { cover_offer(); return; }
+ if (!cover_visible()) { cover_drop(); return; }
+ if (!awake()) return;
+ if (!cover.open()) cover.open(cover_wish.entity, true);
+ if (cover.should_ask(now)) {
+ if (!fresh()) return;
+ cover.ask(now);
+ camera_request(cover.entity, cover_wish.size, cover_wish.background);
+ } else if (cover.should_load(now)) {
+ auto *input = lv_indev_get_next(nullptr);
+ if (input && lv_indev_get_state(input) == LV_INDEV_STATE_PRESSED) return;
+ cover.start(now);
+ camera_full.load(cover.url);
+ }
+}
+
// Asks ESP Screen Manager for a link (app 0.2.66+ answers with op "camera"). An event, like history_request.
-inline void camera_request(const std::string &entity) {
+// A cover (firmware 0.2.64+) adds the size it wants and the colour behind its rounded corners; the app bakes both in.
+inline void camera_request(const std::string &entity, int size, uint32_t background) {
if (inbox.empty()) return;
esphome::api::HomeassistantActionRequest request;
request.service = esphome::StringRef("esphome.screen_camera");
request.is_event = true;
- const std::string keys[] = {"inbox", "entity"}, values[] = {inbox, entity};
- request.data.init(2);
- for (int i = 0; i < 2; ++i) {
+ char size_text[12] = "", background_text[8] = "";
+ if (size > 0) { snprintf(size_text, sizeof(size_text), "%d", size); snprintf(background_text, sizeof(background_text), "%06X", (unsigned) background); }
+ const std::string keys[] = {"inbox", "entity", "size", "bg"}, values[] = {inbox, entity, size_text, background_text};
+ const int count = size > 0 ? 4 : 2;
+ request.data.init(count);
+ for (int i = 0; i < count; ++i) {
esphome::api::HomeassistantServiceMap entry;
entry.key = esphome::StringRef(keys[i]);
entry.value = esphome::StringRef(values[i]);
@@ -3515,7 +3893,9 @@ inline void camera_close() {
inline void camera_open(const std::string &entity, const std::string &name) {
if (!camera_supported() || !valid_entity(entity)) return;
camera_close();
- // The last camera's image goes before this one loads into the same online_image.
+ // The last camera's image, or a media card's cover, goes before this one loads into the same online_image; the
+ // cover is asked for again once the camera closes (cover_tick).
+ cover_release();
if (camera_release_due) camera_release();
camera.open(entity);
const int width = lv_display_get_horizontal_resolution(lv_display_get_default());
@@ -3577,6 +3957,7 @@ inline void camera_tick() {
camera_thumb.load(alert_url);
}
}
+ cover_tick(now);
if (!camera_root || !awake()) return;
if (camera.should_ask(now)) {
if (!fresh()) return;
@@ -3631,6 +4012,12 @@ inline void camera_answer(const std::string &view, const std::string &entity, co
if (url.empty() && !camera.shown) camera_note_text("No image from this camera");
return;
}
+ if (view == "cover") { // the media card's album cover (firmware 0.2.64+)
+ if (!cover.open() || cover.entity != entity) return;
+ cover.link(url);
+ if (url.empty()) ESP_LOGI("camera", "no cover for %s", entity.c_str());
+ return;
+ }
if (url.empty()) { // announced before its alert
alert_announced = entity;
alert_announced_at = esphome::millis();
@@ -3674,7 +4061,12 @@ inline void camera_loaded(bool thumb, bool cached) {
if (alert_picture && alert_frame_icon) lv_obj_add_flag(alert_frame_icon, LV_OBJ_FLAG_HIDDEN);
return;
}
- if (!camera_root) return;
+ if (!camera_root) {
+ // The media card's cover (firmware 0.2.64+): the same online_image, loaded once.
+ if (cover.loading) { cover.finish(esphome::millis(), true); cover_arrived(); }
+ return;
+ }
+ if (!camera.loading) return; // a cover's download that ended after the camera opened: not this camera's picture
camera.finish(esphome::millis(), true);
const bool first = camera_picture == nullptr;
camera_show(camera_root, camera_picture, camera_full.source(), !cached);
@@ -3694,7 +4086,11 @@ inline void camera_failed(bool thumb) {
}
return;
}
- if (!camera_root) return;
+ if (!camera_root) {
+ if (cover.loading) cover.finish(esphome::millis(), false); // tried again after the gap, three times at most
+ return;
+ }
+ if (!camera.loading) return;
camera.finish(esphome::millis(), false);
if (!camera.shown) camera_note_text("No image from this camera");
}
diff --git a/components/smart_display/theme.h b/components/smart_display/theme.h
index 35b7639..91ff1d0 100644
--- a/components/smart_display/theme.h
+++ b/components/smart_display/theme.h
@@ -279,6 +279,9 @@ namespace theme {
inline lv_color_t color(Role role) { return lv_color_hex(hex(role)); }
inline lv_color_t rgb(uint32_t value) { return lv_color_hex(value); }
+// The colour an object draws with, as the 24-bit value the table speaks in (what the media card names as the colour
+// behind its cover's corners).
+inline uint32_t of(lv_color_t value) { return lv_color_to_u32(value) & 0xFFFFFF; }
// ---- paints: shared styles filled from the table
// A board profile gives a widget a paint instead of a colour (`styles: paint_card`). Its style definitions carry no
diff --git a/components/smart_display/tile_controls.h b/components/smart_display/tile_controls.h
index b458d66..83360ff 100644
--- a/components/smart_display/tile_controls.h
+++ b/components/smart_display/tile_controls.h
@@ -16,7 +16,7 @@ namespace tile_controls {
namespace feature {
constexpr uint32_t COVER_OPEN = 1, COVER_CLOSE = 2, COVER_POSITION = 4, COVER_STOP = 8;
constexpr uint32_t COVER_OPEN_TILT = 16, COVER_CLOSE_TILT = 32, COVER_STOP_TILT = 64, COVER_TILT_POSITION = 128;
-constexpr uint32_t MEDIA_PAUSE = 1, MEDIA_VOLUME_SET = 4, MEDIA_VOLUME_MUTE = 8, MEDIA_PREVIOUS = 16, MEDIA_NEXT = 32, MEDIA_PLAY = 16384;
+constexpr uint32_t MEDIA_PAUSE = 1, MEDIA_VOLUME_SET = 4, MEDIA_VOLUME_MUTE = 8, MEDIA_PREVIOUS = 16, MEDIA_NEXT = 32, MEDIA_TURN_ON = 128, MEDIA_PLAY = 16384;
constexpr uint32_t VACUUM_TURN_ON = 1, VACUUM_TURN_OFF = 2, VACUUM_PAUSE = 4, VACUUM_STOP = 8, VACUUM_RETURN = 16, VACUUM_START = 8192;
}
// Material Design Icons glyphs the icon fonts carry (tile_icons.py FIXED).
diff --git a/docs/CAMERA.md b/docs/CAMERA.md
index 0131dba..24f48ef 100644
--- a/docs/CAMERA.md
+++ b/docs/CAMERA.md
@@ -13,6 +13,23 @@ The CYD has no memory for images (a 320×180 image needs 115 KB in one piece, th
free block is about 45 KB). It shows the alert without the picture, and the editor doesn't offer
camera tiles for it.
+## The album cover on the media card
+
+App 0.2.77 with firmware 0.2.64 uses the same road for a media player's picture. The media card
+(a tap on a media player's tile, or a media tile of size *Full page*) shows the album cover of
+what plays, with the title, the artist and the album, a progress bar and the keys under it.
+
+- The screen asks ESP Screen Manager for the cover with the size it draws it at and the colour
+ behind it (the event `esphome.screen_camera` with `size` and `bg`). The app fetches the
+ picture where Home Assistant's state points (`entity_picture`), cuts it square, sizes it,
+ rounds the corners over that colour and serves it on port 8098 as a BMP, like a camera image.
+- A cover is fetched once per picture: the state carries a short mark of the picture, and the
+ screen asks again only when the mark changes (a new track), when the card opens again or
+ when the page turns back to a full-page media tile. Nothing polls.
+- A player without a picture (a radio station, a player that is off) keeps the player's icon in
+ the cover's place; the app answers with an empty link and the screen stops asking.
+- The CYD shows the same card without the picture: its icon stands in for the cover.
+
## A doorbell
```yaml
diff --git a/docs/TEST_RESULTS_0277.md b/docs/TEST_RESULTS_0277.md
new file mode 100644
index 0000000..3ea0857
--- /dev/null
+++ b/docs/TEST_RESULTS_0277.md
@@ -0,0 +1,85 @@
+# Test results app 0.2.77 / firmware 0.2.64 (2026-09-18)
+
+Max asked for a media card "like Spotify or Apple Music now playing" instead of the plain card with three text keys
+and a thin volume slider, with the album cover through the camera-image route, working as the card behind a media
+tile, as a tile over the whole page, and in the light and the dark look. Then: the CYD gets the new card without the
+picture ("te risky"), a player does not always have a picture, and renders first so he could approve the look.
+See CHANGELOG 0.2.77. Built on 0.2.76 (`bdb1f12`).
+
+## What changed
+
+- `components/smart_display/media_card.h` (new): the card's geometry and words, free of LVGL. A tall area (the
+ Guition's card) stacks cover, title, artist · album, the bar row (elapsed | bar | total on one line), the keys
+ (previous 52, play or pause 64 on the accent, next) and the volume row. A wide area (the CYD's card, a tile over the
+ whole page) puts the cover at the left with the column beside it; what does not fit goes in order: the artist line,
+ the times, then the cover shrinks. `tests/test_media_card.cpp` checks every shape for overlaps and order.
+- `runtime_tiles.h`: `render_media_detail` (the card), `render_media_full` (the tile over the whole page, parts in
+ `w.extra`), `media_action` shared by both (20 play/pause, 21 previous, 22 next, 23 mute, 24 turn on), the card's
+ key handler pulled out of `detail_button` into `detail_command`, `media_progress` moved by `tick()` once a second,
+ and the cover: `cover_want`/`cover_ready`/`cover_tick`/`cover_offer` around one `camera_view::Feed` that loads a
+ link once (`once`, `loaded`), owned by the card or by a tile slot and dropped when the owner goes; a camera full
+ screen takes the shared image buffer and the cover comes back after. The camera answer parser accepts `t: cover`.
+ The palette pass leaves the media tile's parts alone (it painted them the card's grey). A player's state falls
+ back to the screen's own word ("Playing") when Home Assistant sent none.
+- `runtime_model.h`: `Extra` carries artist, album, picture mark, duration, position and its moment.
+- `camera_view.h`: `open(entity, once)`, `loaded`; an empty answer to a one-time feed stops the asking.
+- `theme.h`: `theme::of(lv_color_t)` (the 24-bit value of a drawn colour, for the cover's corner colour).
+- `tile_controls.h`: `feature::MEDIA_TURN_ON`.
+- App: `core.media_extras` (`x: artist, album, dur, pos, at, pic`), `COVER_MIN_FIRMWARE = 0.2.64`;
+ `camera_feed.cover_supported/can_show_cover/cover_request/encode_cover`, `CameraFeed.cover()` (fetched when the
+ picture address changed), `Link.cover`; `server.HomeAssistant.media_picture/media_image`, `Manager.cover_message`,
+ `answer_camera` serves covers to a Guition on 0.2.64+ for players on its layout.
+- Both profiles: firmware 0.2.64. Docs: CHANGELOG, README rows, docs/CAMERA.md "The album cover on the media card".
+
+## Checks
+
+- `tests/test_media_card.cpp` (geometry, progress, clock text, subtitle), `tests/test_camera_view.cpp` (one-time
+ feed), all 18 C++ suites: pass.
+- `tests/test_media_card.py` (14): extras, firmware wiring, cover encoding (corners, square crop, transparency),
+ fetch-on-change and links, the app's answer through `Manager.answer_camera` (Guition 0.2.64 gets a link, a stream
+ an empty one, a stranger nothing, older firmware and a CYD nothing). Whole Python suite: 409 + 14, pass.
+- Host harness (`.esphome/readme-render/media/media_host.py`, real ESPHome API, virtual finger, the app's own
+ `answer_camera` behind `/dev/cover`): **Guition 57/57, CYD 42/42.** Covered: the card of a playing player (title,
+ artist · album, elapsed from the reported position and running on, keys enabled, slider at 32 %, bar track and
+ fill), the cover asked for with size and page colour and loaded once over the placeholder, pause → `media_play_pause`
+ with the keys faded during the wait and back after, next, mute (`is_volume_muted: true`), the slider drag →
+ `volume_set` ≈ 0.8, closing drops the cover; a stream (no times, no cover request); a paused muted TV with few
+ features (play key, muted key, "Muted", next and mute faded, previous not, a still bar, its cover); an off player
+ ("Off", no times); dark mode (dark keys, the cover asked again with the dark page colour); a tile over the whole
+ page (track beside the cover, keys, slider, "48 %", times, cover at the tile's size and colour, the bar running,
+ the tile's pause key acting on the study speaker without opening the card, the tile's slider → `volume_set` ≈ 0.2,
+ a new picture in Home Assistant → new mark → the tile asks again and loads the new cover, holding the tile opens the
+ card which takes the cover at its own size, closing gives it back to the tile); a stream over the page (no times,
+ no cover, the study cover dropped); an idle player ("Not playing"); a wide media tile with a volume control keeps
+ its panel; no errors in the firmware log. The CYD asks for no cover anywhere.
+- Renders (`render_media.py`, both boards, light and dark): card of a playing player, a stream, a paused TV, an off
+ player, tiles over the page (study, radio, idle). Sent to Max for approval.
+- ESP32 builds of both Easy Setup profiles: see the sizes below.
+- Hardware: Studio 1 (Guition, this network's Home Assistant) — see below.
+
+## Sizes
+
+Both Easy Setup profiles compile with ESPHome 2026.6.2 (on Python 3.11, see the pitfalls), no warnings.
+
+| Build | Flash | RAM |
+|---|---|---|
+| Guition (easy-guition-device) | 1,971,583 B, 24.3 % of 8 MB | 76,844 B, 23.5 % |
+| CYD (easy-cyd-device) | 1,582,063 B, 86.2 % of the 1.835 MB update slot | 71,276 B, 21.8 % |
+
+## Hardware
+
+Pending at the time of writing.
+
+## Pitfalls
+
+- `render_time` on a host build freezes the clock, and the progress bar with it: the harness shifts the demo's
+ report moments to now instead.
+- LVGL's `LV_LABEL_LONG_DOT` writes the dots into the label's text: a probe sees "Miles Davis · Kin...".
+- `tick()` re-enables every object in `detail_actions` after a wait: a key the player lacks must stay out of it.
+- The tile palette pass paints every custom part; a mode that paints its own parts must be skipped there.
+- Homebrew's python@3.14.7 on this Mac has a broken `pyexpat` (it wants a libexpat symbol macOS 26.2 lacks), so
+ `platform.mac_ver()` returns '' and both pip's truststore and `uv` refuse the interpreter. The pioarduino platform
+ recreates `~/.platformio/penv` from the running Python and then cannot install into it. The ESP32 builds of this
+ release ran from an ESPHome 2026.6.2 environment on PlatformIO's own bundled Python 3.11 (`.esphome/venv311`,
+ local); `brew reinstall python@3.14` (or expat) is the real fix. Also: never run two ESPHome builds at once, the
+ second one clears the penv the first one uses.
diff --git a/docs/images/cyd-dark-media.png b/docs/images/cyd-dark-media.png
new file mode 100644
index 0000000000000000000000000000000000000000..6374872b25fc167c0f7835e173f7da345353f2ea
GIT binary patch
literal 11789
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
cik=rI@qT;z>49Lc2n`zI^NJMzD6w01$#7wdl
zCS_-gklk2@8O-*(X?dQ{`+NWSzVCmx+r3=(d0ppmoY!$46M4m0mut7+ZU6wdV0xF$
z0DuJv0Fbf2*ugW3-6N9#px_I;eBoNq&>}wcMbCQp(h9${^k+M*%lCF)Sj#QA*gZ5R
zUeJHdYBV%e2{U*%ixzU;9#2o@ik{rddhQBf)8u}V?7y1XTd`tDbM
zJW(>@`>SR&0b5=wE&W0>f*Ndy&2V;y3u6`k`gLI$``zOrG#Co1*muM`xAhWuZ&{22
z9lSdGrI{A57Wf~*vyh)B
0`dO*z}Z_J%eIm*
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