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
+12 -3
View File
@@ -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; }
}
};
+156
View File
@@ -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 <algorithm>
#include <cstdint>
#include <cstdio>
#include <string>
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<int>(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
+7 -1
View File
@@ -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<std::string> 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();
}
+445 -49
View File
@@ -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<uint32_t>(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<lv_obj_t *, 36> 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<bool>() && a["is_volume_muted"].as<bool>();
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<uint32_t>();
// 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<unsigned>()?extra["dur"].as<uint32_t>():0;
next.media_position=extra["pos"].is<unsigned>()?extra["pos"].as<uint32_t>():0;
next.media_position_at=extra["at"].is<unsigned>()?extra["at"].as<uint32_t>():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,"history range")&&history_hours!=hours[cmd-160]){
history_hours=hours[cmd-160];
lv_async_call([](void *){if(detail_root&&!lv_obj_has_flag(detail_root,LV_OBJ_FLAG_HIDDEN))show_detail(detail_index);},nullptr);
}
return;
}
if(!fresh()||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<int,char> rows[]={{10,'s'},{50,'m'},{60,'w'}};
for(const auto &[first,kind]:rows)
if(cmd>=first && cmd<first+6){auto *row=t.choice(kind);if(row && cmd-first<(int)row->values.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,"history range")&&history_hours!=hours[cmd-160]){
history_hours=hours[cmd-160];
lv_async_call([](void *){if(detail_root&&!lv_obj_has_flag(detail_root,LV_OBJ_FLAG_HIDDEN))show_detail(detail_index);},nullptr);
}
return;
}
if(!fresh()||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<int,char> rows[]={{10,'s'},{50,'m'},{60,'w'}};
for(const auto &[first,kind]:rows)
if(cmd>=first && cmd<first+6){auto *row=t.choice(kind);if(row && cmd-first<(int)row->values.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<lv_obj_t *> 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<options.size();++i)detail_button(options[i].c_str(),pad+(i%2)*(cw+gap),top+(i/2)*(bh+gap),cw,bh,30+i);
}else if(d=="media_player"){
detail_label(detail_root,t.extra().media_title,pad,top,width-2*pad);top+=large?45:25;
int w=(width-pad*2-2*gap)/3;
detail_button("Previous",pad,top,w,bh,21);detail_button("Play/pause",pad+w+gap,top,w,bh,20);detail_button("Next",pad+2*(w+gap),top,w,bh,22);top+=bh+gap;
detail_label(detail_root,"Volume",pad,top,width-2*pad);
auto *slider=lv_slider_create(detail_root);lv_obj_set_pos(slider,pad+12,top+(large?52:34));lv_obj_set_size(slider,width-2*pad-24,large?24:16);lv_slider_set_range(slider,0,1000);lv_slider_set_value(slider,slider_value(t),LV_ANIM_OFF);lv_obj_set_style_bg_color(slider,theme::color(theme::SLIDER_KNOB),LV_PART_KNOB);
lv_obj_add_event_cb(slider,slider_event,LV_EVENT_ALL,(void*)(uintptr_t)index);
// "Now playing" (firmware 0.2.64+): the cover, the track, a running progress bar, round keys and the volume row.
render_media_detail(t,index,large,width,height,bar_y+bar+(large?8:4));
}else if(d=="weather"){
render_weather_detail(t,large,width,height,pad);
}else if(d=="timer"){
@@ -1949,13 +2139,13 @@ inline void event(lv_event_t *event) {
}
// The same guard for any local style: a page switch mostly hands a slot the colours it already has,
// and each real set refreshes the style and invalidates the object (about 0.3 ms on the Guition).
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_color(lv_obj_t *obj, lv_style_prop_t prop, lv_color_t color, lv_style_selector_t selector) {
lv_style_value_t current;
if (lv_obj_get_local_style_prop(obj, prop, &current, selector) == LV_STYLE_RES_FOUND && lv_color_eq(current.color, color)) return;
lv_style_value_t value{}; value.color = color;
lv_obj_set_local_style_prop(obj, prop, value, selector);
}
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_number(lv_obj_t *obj, lv_style_prop_t prop, int32_t number, lv_style_selector_t selector) {
lv_style_value_t current;
if (lv_obj_get_local_style_prop(obj, prop, &current, selector) == LV_STYLE_RES_FOUND && current.num == number) return;
lv_style_value_t value{}; value.num = number;
@@ -2596,6 +2786,91 @@ inline void set_busy(Widgets &w,bool busy,bool large){
// small slider, direct controls or a graph the double-width card's head stays on top and the control takes
// a strip at the bottom, so a tap anywhere else still does what a tap on the tile does. The built-in cards
// (clock, forecast, sun path) simply get the whole page.
// A media player over the whole page (firmware 0.2.64+): the head as on every full card, and under it the media card
// itself, wide: the cover at the left, the track, the bar and the keys beside it, the volume row along the bottom.
// Parts: 0 placeholder, 1 its icon, 2 title, 3 artist, 4 track, 5 fill, 6 elapsed, 7 total, 8-10 keys, 11 mute,
// 12 slider, 13 percent, MEDIA_PICTURE (14) the cover. Built once per slot and moved on every redraw.
inline void media_tile_key_event(lv_event_t *e){
unsigned code=(uintptr_t)lv_event_get_user_data(e);unsigned slot=code/16,n=code%16;
if(slot>=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<w.parts.size();++i){
// The media tile (firmware 0.2.64+) paints its own parts on every render: keys, the bar and the cover's placeholder
// in the media colours, not the card's.
for(unsigned i=0;i<w.parts.size() && w.extra_mode!="media";++i){
auto *p=w.parts[i];if(!p)continue;
bool muted=w.extra_mode=="forecast" ? 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)
media_progress(model.tiles[detail_index],media_progress_fill,media_elapsed_label,media_bar_width);
auto now=now_time?now_time():esphome::ESPTime{};
int minute=now.is_valid()?now.day_of_year*1440+now.hour*60+now.minute:-1;
bool new_minute=minute!=last_clock_minute;last_clock_minute=minute;
@@ -3418,6 +3707,8 @@ inline void tick() {
auto &w=widgets[slot];if(!w.tile || w.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<bool>(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");
}
+3
View File
@@ -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
+1 -1
View File
@@ -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).