v2 Phase 5: Now Playing — Windows media session on the watch

The daemon reads the Windows "now playing" media session (WinRT SMTC) and
adds np/nt/na to the BLE payload; the watch shows a play/pause glyph, a
scrolling title, artist and status. Best-effort and token-independent, so
it works even when the rate-limit data is unavailable.

Cyrillic titles: Styrene B has no Cyrillic glyphs, so the title/artist use
new composite fonts (font_styrene_cyr_{28,20}) — Latin from Styrene B,
Cyrillic + typographic punctuation from Montserrat, merged by lv_font_conv.
Latin titles stay on-brand; only Cyrillic falls back to Montserrat.

- daemon: read_now_playing() via winrt-Windows.Media.Control (lazy import,
  never raises; PLAYING->1, PAUSED->2, else 0; title/artist truncated,
  empty fields omitted)
- firmware: UsageData np_state/np_title/np_artist + parser; the real Now
  Playing screen replaces the shared stub (SCREEN_NOWPLAYING)
- fonts: assets/Montserrat-Medium.ttf, font_styrene_cyr_{28,20}.c
- tools: patch_lvgl9_font.py (automates the 4 LVGL 9 font patches),
  screenshot_win.py (Windows serial screenshot via pyserial + Pillow)
- README: document Cyrillic composite font generation

Verified on hardware (waveshare_amoled_206): idle state, a live Latin
title (scrolling), and Cyrillic (Prohozhdenie / Dunduk) all render.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wenil
2026-06-21 00:41:15 +03:00
co-authored by Claude Opus 4.8
parent e682b43735
commit b8d4daa03f
10 changed files with 9293 additions and 1 deletions
+80 -1
View File
@@ -14,6 +14,10 @@ LV_FONT_DECLARE(font_styrene_24);
LV_FONT_DECLARE(font_styrene_20);
LV_FONT_DECLARE(font_styrene_16);
LV_FONT_DECLARE(font_styrene_14);
// Composite Styrene (Latin) + Montserrat (Cyrillic) — used where user content
// (media titles) can be non-Latin; brand Styrene has no Cyrillic glyphs.
LV_FONT_DECLARE(font_styrene_cyr_28);
LV_FONT_DECLARE(font_styrene_cyr_20);
LV_FONT_DECLARE(font_mono_32);
// Layout values computed from the active board's geometry. Populated once
@@ -134,6 +138,11 @@ static lv_obj_t* sess_cost_lbl;
static lv_obj_t* sess_tokens_lbl;
static lv_obj_t* sess_gen_lbl;
static lv_obj_t* sess_msgs_lbl;
static lv_obj_t* nowplaying_container; // media now-playing (Phase 5)
static lv_obj_t* np_icon_lbl; // play / pause / music glyph
static lv_obj_t* np_title_lbl; // track title (scrolls if long)
static lv_obj_t* np_artist_lbl; // track artist
static lv_obj_t* np_status_lbl; // "Playing" / "Paused"
// App registry — the launcher renders one tile per entry, so adding a screen is
// one line here plus its builder. Order = tile order. "Animations" reuses the
@@ -734,6 +743,53 @@ static void init_session_screen(lv_obj_t* scr) {
lv_obj_add_flag(session_container, LV_OBJ_FLAG_HIDDEN);
}
// Now Playing: the Windows media session relayed by the daemon. A play/pause glyph
// and status line reflect playback; the title scrolls if it overflows. Independent
// of rate-limit data, so it stays live even when the OAuth token is unavailable.
static void init_nowplaying_screen(lv_obj_t* scr) {
nowplaying_container = lv_obj_create(scr);
lv_obj_set_size(nowplaying_container, L.scr_w, L.scr_h);
lv_obj_set_pos(nowplaying_container, 0, 0);
lv_obj_set_style_bg_opa(nowplaying_container, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(nowplaying_container, 0, 0);
lv_obj_set_style_pad_all(nowplaying_container, 0, 0);
lv_obj_clear_flag(nowplaying_container, LV_OBJ_FLAG_SCROLLABLE);
make_screen_title(nowplaying_container, "Now Playing");
np_icon_lbl = lv_label_create(nowplaying_container);
lv_label_set_text(np_icon_lbl, LV_SYMBOL_AUDIO);
lv_obj_set_style_text_font(np_icon_lbl, &lv_font_montserrat_28, 0);
lv_obj_set_style_text_color(np_icon_lbl, COL_DIM, 0);
lv_obj_align(np_icon_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 35);
np_title_lbl = lv_label_create(nowplaying_container);
lv_obj_set_width(np_title_lbl, L.content_w);
lv_label_set_long_mode(np_title_lbl, LV_LABEL_LONG_SCROLL_CIRCULAR);
lv_obj_set_style_text_align(np_title_lbl, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_style_text_font(np_title_lbl, &font_styrene_cyr_28, 0);
lv_obj_set_style_text_color(np_title_lbl, COL_TEXT, 0);
lv_label_set_text(np_title_lbl, "Nothing playing");
lv_obj_align(np_title_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 120);
np_artist_lbl = lv_label_create(nowplaying_container);
lv_obj_set_width(np_artist_lbl, L.content_w);
lv_label_set_long_mode(np_artist_lbl, LV_LABEL_LONG_DOT);
lv_obj_set_style_text_align(np_artist_lbl, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_style_text_font(np_artist_lbl, &font_styrene_cyr_20, 0);
lv_obj_set_style_text_color(np_artist_lbl, COL_DIM, 0);
lv_label_set_text(np_artist_lbl, "");
lv_obj_align(np_artist_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 170);
np_status_lbl = lv_label_create(nowplaying_container);
lv_obj_set_style_text_font(np_status_lbl, &font_styrene_16, 0);
lv_obj_set_style_text_color(np_status_lbl, COL_DIM, 0);
lv_label_set_text(np_status_lbl, "");
lv_obj_align(np_status_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 215);
lv_obj_add_flag(nowplaying_container, LV_OBJ_FLAG_HIDDEN);
}
// ======== Public API ========
void ui_init(void) {
@@ -759,6 +815,7 @@ void ui_init(void) {
init_bt_screen(scr);
init_soundpad_screen(scr);
init_session_screen(scr);
init_nowplaying_screen(scr);
logo_img = lv_image_create(scr);
lv_image_set_src(logo_img, &logo_dsc);
@@ -801,6 +858,27 @@ void ui_update(const UsageData* data) {
lv_label_set_text_fmt(sess_msgs_lbl, "%d requests", data->messages_today);
}
// Now Playing — local media session relayed by the daemon. Like Session, it's
// independent of the rate-limit data, so refresh it before the ok==false bail.
if (nowplaying_container) {
if (data->np_state == 0) {
lv_label_set_text(np_icon_lbl, LV_SYMBOL_AUDIO);
lv_obj_set_style_text_color(np_icon_lbl, COL_DIM, 0);
lv_label_set_text(np_title_lbl, "Nothing playing");
lv_obj_set_style_text_color(np_title_lbl, COL_DIM, 0);
lv_label_set_text(np_artist_lbl, "");
lv_label_set_text(np_status_lbl, "");
} else {
bool playing = (data->np_state == 1);
lv_label_set_text(np_icon_lbl, playing ? LV_SYMBOL_PLAY : LV_SYMBOL_PAUSE);
lv_obj_set_style_text_color(np_icon_lbl, COL_ACCENT, 0);
lv_label_set_text(np_title_lbl, data->np_title[0] ? data->np_title : "(no title)");
lv_obj_set_style_text_color(np_title_lbl, COL_TEXT, 0);
lv_label_set_text(np_artist_lbl, data->np_artist);
lv_label_set_text(np_status_lbl, playing ? "Playing" : "Paused");
}
}
// Rate-limit utilization (5h / 7d). The daemon sets ok=false when this data
// is unavailable (expired OAuth token, API down). Skip the bars and DON'T
// bump the freshness clock then, so the usage view falls back to its idle
@@ -934,6 +1012,7 @@ void ui_show_screen(screen_t screen) {
if (bt_container) lv_obj_add_flag(bt_container, LV_OBJ_FLAG_HIDDEN);
if (soundpad_container) lv_obj_add_flag(soundpad_container, LV_OBJ_FLAG_HIDDEN);
if (session_container) lv_obj_add_flag(session_container, LV_OBJ_FLAG_HIDDEN);
if (nowplaying_container) lv_obj_add_flag(nowplaying_container, LV_OBJ_FLAG_HIDDEN);
splash_hide();
switch (screen) {
@@ -942,8 +1021,8 @@ void ui_show_screen(screen_t screen) {
case SCREEN_MENU: lv_obj_clear_flag(menu_container, LV_OBJ_FLAG_HIDDEN); break;
case SCREEN_SOUNDPAD: lv_obj_clear_flag(soundpad_container, LV_OBJ_FLAG_HIDDEN); break;
case SCREEN_SESSION: lv_obj_clear_flag(session_container, LV_OBJ_FLAG_HIDDEN); break;
case SCREEN_NOWPLAYING: lv_obj_clear_flag(nowplaying_container, LV_OBJ_FLAG_HIDDEN); break;
case SCREEN_BLUETOOTH: bt_refresh(); lv_obj_clear_flag(bt_container, LV_OBJ_FLAG_HIDDEN); break;
case SCREEN_NOWPLAYING:
case SCREEN_HOMEASSIST:
stub_show_for(screen);
lv_obj_clear_flag(stub_container, LV_OBJ_FLAG_HIDDEN);