diff --git a/examples/companion_radio/ui-lvgl/LvglPort.h b/examples/companion_radio/ui-lvgl/LvglPort.h index 95ca2a38..3cac2667 100644 --- a/examples/companion_radio/ui-lvgl/LvglPort.h +++ b/examples/companion_radio/ui-lvgl/LvglPort.h @@ -26,6 +26,17 @@ namespace lvport { +// Screenshot: while s_shot points at a full-screen RGB565 buffer, the flush +// callbacks also copy what they send to the panel into it. +static uint16_t* s_shot = nullptr; +static void shotCopy(const lv_area_t* a, const uint8_t* px) { + if (!s_shot) return; + const int32_t W = lv_display_get_horizontal_resolution(NULL); + const int32_t w = a->x2 - a->x1 + 1; + const uint16_t* src = (const uint16_t*)px; + for (int32_t y = a->y1; y <= a->y2; y++, src += w) memcpy(s_shot + y * W + a->x1, src, w * 2); +} + // Network for map downloads: state of the link, and one HTTP GET at a time. enum NetState { NET_OFF, NET_CONNECTING, NET_UP, NET_FAILED }; static const int WIFI_SCAN_MAX = 20; @@ -41,6 +52,7 @@ static void flushCb(lv_display_t* disp, const lv_area_t* area, uint8_t* px_map) #ifdef UI_PERF_TEST uint32_t t = micros(); #endif + shotCopy(area, px_map); int w = area->x2 - area->x1 + 1; int h = area->y2 - area->y1 + 1; s_gfx->startWrite(); @@ -445,6 +457,7 @@ static const char* fetchError() { return s_ferr; } static bool s_swallow = false; static void flushCb(lv_display_t* disp, const lv_area_t* area, uint8_t* px_map) { + shotCopy(area, px_map); display.blit(area->x1, area->y1, area->x2 - area->x1 + 1, area->y2 - area->y1 + 1, (const uint16_t*)px_map); lv_display_flush_ready(disp); diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index 3356a013..046d3a05 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -7,6 +7,7 @@ #endif #include #include +#include // Flags: LVGL draws text one codepoint at a time, so a flag (a pair of regional // indicator letters) would show as two letter tiles. Every label and span of @@ -101,6 +102,8 @@ static bool s_sim_btn_click = false, s_sim_btn_hold = false, s_sim_wake = false; extern "C" EMSCRIPTEN_KEEPALIVE void sim_lcd_button() { s_sim_btn_click = true; } extern "C" EMSCRIPTEN_KEEPALIVE void sim_lcd_button_hold() { s_sim_btn_hold = true; } extern "C" EMSCRIPTEN_KEEPALIVE void sim_wake_button() { s_sim_wake = true; } +static bool s_sim_shot = false; // the side + top button combo +extern "C" EMSCRIPTEN_KEEPALIVE void sim_screenshot() { s_sim_shot = true; } // The speaker, polled every frame by the page's Web Audio oscillator. extern "C" EMSCRIPTEN_KEEPALIVE int sim_buzzer_is_playing() { return s_ui && s_ui->isBuzzerPlaying() ? 1 : 0; } extern "C" EMSCRIPTEN_KEEPALIVE int sim_buzzer_freq_hz() { return s_ui ? (int)s_ui->buzzerFreqHz() : 0; } @@ -846,11 +849,13 @@ void UITask::toggleMute() { #endif } +static constexpr uint32_t LOCK_OFF_MS = 30000; // the lock screen's own auto-off cap + void UITask::loop() { pollConnection(); drainCoreEvents(); - // USER (BOOT, side) button: Home's clock page; held, mutes / unmutes, also with the + // USER (BOOT, side) button: Home's clock page; held and let go, mutes / unmutes, also with the // screen off, which it doesn't wake. WAKE (top) button: screen off / on. // Either one silences a ringing alarm first. bool btn_click = false, btn_hold = false, wake_press = false; @@ -872,9 +877,25 @@ void UITask::loop() { _wake_down = down; } #endif - if ((btn_click || btn_hold || wake_press) && _core->clock.isRinging()) dismissRing(); + // Side button held + top button: a screenshot (and neither does its own thing). + // A long hold only arms the mute, which happens on letting go -- so holding + // the side button a bit long before the top one doesn't mute as well. + static bool mute_armed = false; + bool shot = false, mute = false; +#ifdef PIN_USER_BTN + bool side_down = user_btn.isPressed(); + if (wake_press && side_down) { user_btn.cancelClick(); shot = true; mute_armed = false; } + if (mute_armed && !side_down) { mute_armed = false; mute = true; } +#elif defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__) + shot = s_sim_shot; + s_sim_shot = false; + if (mute_armed) { mute_armed = false; mute = true; } // the page sends the hold on release +#endif + if (shot) { if (!_asleep) takeScreenshot(); } + else if ((btn_click || btn_hold || wake_press) && _core->clock.isRinging()) dismissRing(); else if (wake_press) { if (_asleep) wake(); else sleep(); } - else if (btn_hold) toggleMute(); + else if (btn_hold) mute_armed = true; + else if (mute) toggleMute(); else if (btn_click) { if (!_asleep && !locked()) goHome(); } // the side button doesn't wake: pockets if (_asleep) { @@ -884,7 +905,10 @@ void UITask::loop() { if (lvport::touched()) { lvport::swallowTouch(); wake(); } } } else { + // Locked, nobody's using it: the lock screen goes dark after LOCK_OFF_MS + // even with auto-off at Never (a woken-up pocket, a boot PIN, a message). uint32_t aoff = autoOffMillis(); + if (locked() && (aoff == 0 || aoff > LOCK_OFF_MS)) aoff = LOCK_OFF_MS; if (aoff > 0 && lv_display_get_inactive_time(NULL) > aoff && !_core->clock.isRinging() && !otaBusy()) sleep(); } @@ -1289,6 +1313,72 @@ static void toastTimerCb(lv_timer_t* t) { lv_timer_pause(t); } +// The screen as it is, toasts and popups included, re-rendered once into a +// buffer the flush callback fills (lvport::shotCopy) and written to the card +// as a 24-bit BMP: /sdcard/screenshots/scr_NNNN.bmp. +void UITask::takeScreenshot() { + lv_display_t* d = lv_display_get_default(); + const int32_t W = lv_display_get_horizontal_resolution(d), H = lv_display_get_vertical_resolution(d); + if (!lvport::mountStorage()) { showToast("No SD card"); return; } + mkdir("/sdcard", 0777); // the sim's MEMFS may not have it yet (the card: fails, harmless) + mkdir("/sdcard/screenshots", 0777); + char path[48]; + struct stat st; + int n = 1; + for (; n <= 9999; n++) { + snprintf(path, sizeof(path), "/sdcard/screenshots/scr_%04d.bmp", n); + if (stat(path, &st) != 0) break; + } + if (n > 9999) { showToast("Screenshots folder full"); return; } +#ifdef ESP32 + uint16_t* px = (uint16_t*)heap_caps_malloc(W * H * 2, MALLOC_CAP_SPIRAM); +#else + uint16_t* px = (uint16_t*)malloc(W * H * 2); +#endif + if (!px) { showToast("Out of memory"); return; } + + lvport::s_shot = px; + lv_obj_invalidate(lv_screen_active()); // the whole display, every layer + lv_refr_now(d); + lvport::s_shot = nullptr; + + bool ok = false; + if (FILE* f = fopen(path, "wb")) { + const uint32_t row = (uint32_t)W * 3, size = 54 + row * H; // W*3 is a multiple of 4 at 320 + uint8_t hdr[54] = {'B', 'M'}; + auto put32 = [&](int at, uint32_t v) { for (int i = 0; i < 4; i++) hdr[at + i] = (uint8_t)(v >> (8 * i)); }; + put32(2, size); put32(10, 54); put32(14, 40); put32(18, W); put32(22, H); + hdr[26] = 1; hdr[28] = 24; put32(34, row * H); + ok = fwrite(hdr, 1, 54, f) == 54; + uint8_t line[320 * 3]; + for (int32_t y = H - 1; ok && y >= 0; y--) { // bottom-up, BGR + const uint16_t* s = px + y * W; + for (int32_t x = 0; x < W && x < 320; x++) { + uint16_t c = s[x]; + uint8_t r = (c >> 11) & 0x1F, g = (c >> 5) & 0x3F, b = c & 0x1F; + line[x * 3 + 0] = (uint8_t)((b << 3) | (b >> 2)); + line[x * 3 + 1] = (uint8_t)((g << 2) | (g >> 4)); + line[x * 3 + 2] = (uint8_t)((r << 3) | (r >> 2)); + } + ok = fwrite(line, 1, row, f) == row; + } + ok = (fclose(f) == 0) && ok; + } + free(px); + if (!ok) { remove(path); showToast("Couldn't save the screenshot"); return; } + + // A white flash, like a camera's, then where it went. + lv_obj_t* flash = lv_obj_create(lv_layer_top()); + lv_obj_remove_style_all(flash); + lv_obj_set_size(flash, LV_PCT(100), LV_PCT(100)); + lv_obj_set_style_bg_color(flash, lv_color_white(), 0); + lv_obj_remove_flag(flash, LV_OBJ_FLAG_CLICKABLE); + anim::run(flash, anim::setBgOpa, LV_OPA_70, LV_OPA_TRANSP, 300, anim::coverDone); + char msg[40]; + snprintf(msg, sizeof(msg), "Saved scr_%04d.bmp", n); + showToast(msg); +} + void UITask::showToast(const char* text, uint32_t ms) { if (!_toast) return; lv_label_set_text(lv_obj_get_child(_toast, 0), text); diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index 9803e6d5..05c701e8 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -401,6 +401,7 @@ private: void wake(); void sleep(); void toggleMute(); + void takeScreenshot(); uint32_t _next_wake_poll_ms = 0; uint32_t _next_touch_poll_ms = 0; // tap to wake, while the screen is off bool _wake_down = false; diff --git a/variants/sim/web/lvgl.html b/variants/sim/web/lvgl.html index 79f2eae9..dd7da5a7 100644 --- a/variants/sim/web/lvgl.html +++ b/variants/sim/web/lvgl.html @@ -67,6 +67,7 @@
+
@@ -283,6 +284,23 @@ btnDownAt = 0; }); document.getElementById('wake-L').addEventListener('click', () => L && L.call('sim_wake_button', null)); + // The device's own screenshot (the button combo), then the newest file + // from its /sdcard/screenshots handed to the browser as a download. + document.getElementById('shot-L').addEventListener('click', async () => { + if (!L) return; + L.call('sim_screenshot', null); + await sleep(300); + const FS = L.mod.FS, dir = '/sdcard/screenshots'; + let names = []; + try { names = FS.readdir(dir).filter(n => n.endsWith('.bmp')).sort(); } catch (e) {} + if (!names.length) { log('screenshot: nothing saved'); return; } + const name = names[names.length - 1]; + const a = document.createElement('a'); + a.href = URL.createObjectURL(new Blob([FS.readFile(dir + '/' + name)], { type: 'image/bmp' })); + a.download = name; + a.click(); + log('screenshot: ' + name); + }); document.getElementById('reset-L').addEventListener('click', () => bootL()); document.getElementById('advert').addEventListener('click', advertBoth); document.getElementById('peer-dm').addEventListener('click', () => {