378d0b3e61
The firmware takes its texts from screen_manager/translations/<code>.json at build time (components/smart_display: one table in main.cpp, keys in screen_text_keys.h), and the fonts carry every letter European languages write with. The app keeps Settings -> Language & region (language, clock, numbers) for every screen, writes LANGUAGE into the screens' YAML and sends the clock and the number format in the layout message; the per-screen 24-hour clock goes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
158 lines
8.8 KiB
C++
158 lines
8.8 KiB
C++
#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>
|
||
#include "screen_text.h"
|
||
|
||
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 screen_text::tr(screen_text::txt::ha_off);
|
||
if (state == "standby") return screen_text::tr(screen_text::txt::ha_media_standby);
|
||
if (state == "buffering") return screen_text::tr(screen_text::txt::media_loading);
|
||
if (state == "unavailable") return screen_text::tr(screen_text::txt::tile_unavailable);
|
||
return screen_text::tr(screen_text::txt::media_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
|