Add CYD photo album with jpg/png/gif viewer and sample assets.
Ship TF-card album UI, half-res decode canvas, prebuilt firmware, and documented flash/upload workflow. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
91
src/main.cpp
Normal file
91
src/main.cpp
Normal file
@@ -0,0 +1,91 @@
|
||||
#include <Arduino.h>
|
||||
#include <lvgl.h>
|
||||
|
||||
#include "pins.h"
|
||||
#include "photo_view.h"
|
||||
#include "sd_info.h"
|
||||
#include "sd_put.h"
|
||||
#include "touch.h"
|
||||
#include "ui.h"
|
||||
|
||||
static constexpr uint32_t DRAW_BUF_SIZE =
|
||||
(SCREEN_WIDTH * SCREEN_HEIGHT / 10 * (LV_COLOR_DEPTH / 8));
|
||||
static uint32_t draw_buf[DRAW_BUF_SIZE / 4];
|
||||
|
||||
static uint32_t last_refresh_ms = 0;
|
||||
static constexpr uint32_t REFRESH_MS = 10000;
|
||||
|
||||
static void refresh_sd_ui(bool rescan_photos) {
|
||||
SdCardInfo info = sd_read_info();
|
||||
|
||||
Serial.println("---- TF Card ----");
|
||||
if (!info.mounted) {
|
||||
Serial.printf("Error: %s\n", info.error.c_str());
|
||||
} else {
|
||||
Serial.printf("Type: %s FS: %s Cap: %s\n", info.type, info.filesystem,
|
||||
format_bytes(info.card_bytes).c_str());
|
||||
}
|
||||
|
||||
ui_update(info);
|
||||
if (rescan_photos && info.mounted) {
|
||||
ui_reload_album();
|
||||
}
|
||||
}
|
||||
|
||||
void setup() {
|
||||
Serial.setRxBufferSize(16384);
|
||||
Serial.begin(460800);
|
||||
delay(200);
|
||||
Serial.println();
|
||||
Serial.printf("LVGL %d.%d.%d | CYD Photo Album\n", lv_version_major(),
|
||||
lv_version_minor(), lv_version_patch());
|
||||
Serial.println("BOOT_OK");
|
||||
|
||||
pinMode(TFT_BL_PIN, OUTPUT);
|
||||
digitalWrite(TFT_BL_PIN, HIGH);
|
||||
|
||||
touch_init();
|
||||
|
||||
if (!photo_view_init()) {
|
||||
Serial.println("WARN: photo canvas alloc failed");
|
||||
}
|
||||
|
||||
lv_init();
|
||||
|
||||
lv_display_t *disp =
|
||||
lv_tft_espi_create(SCREEN_WIDTH, SCREEN_HEIGHT, draw_buf, sizeof(draw_buf));
|
||||
lv_display_set_rotation(disp, LV_DISPLAY_ROTATION_270);
|
||||
|
||||
lv_indev_t *indev = lv_indev_create();
|
||||
lv_indev_set_type(indev, LV_INDEV_TYPE_POINTER);
|
||||
lv_indev_set_read_cb(indev, touch_read_cb);
|
||||
lv_indev_set_display(indev, disp);
|
||||
|
||||
ui_create();
|
||||
|
||||
if (!sd_init()) {
|
||||
Serial.println("SD mount failed at boot");
|
||||
}
|
||||
|
||||
refresh_sd_ui(true);
|
||||
last_refresh_ms = millis();
|
||||
}
|
||||
|
||||
void loop() {
|
||||
if (sd_put_poll()) {
|
||||
refresh_sd_ui(true);
|
||||
}
|
||||
|
||||
lv_timer_handler();
|
||||
lv_tick_inc(5);
|
||||
ui_tick();
|
||||
|
||||
const uint32_t now = millis();
|
||||
if (now - last_refresh_ms >= REFRESH_MS) {
|
||||
last_refresh_ms = now;
|
||||
// Keep TF status fresh; do not rescan photos (avoids reset while viewing)
|
||||
refresh_sd_ui(false);
|
||||
}
|
||||
|
||||
delay(5);
|
||||
}
|
||||
101
src/photo_list.cpp
Normal file
101
src/photo_list.cpp
Normal file
@@ -0,0 +1,101 @@
|
||||
#include "photo_list.h"
|
||||
|
||||
#include <SD.h>
|
||||
#include <string.h>
|
||||
|
||||
static PhotoEntry g_list[PHOTO_MAX];
|
||||
static uint16_t g_count = 0;
|
||||
|
||||
static bool ends_with_ci(const char *name, const char *ext) {
|
||||
const size_t n = strlen(name);
|
||||
const size_t e = strlen(ext);
|
||||
if (n < e) {
|
||||
return false;
|
||||
}
|
||||
for (size_t i = 0; i < e; i++) {
|
||||
char a = name[n - e + i];
|
||||
char b = ext[i];
|
||||
if (a >= 'A' && a <= 'Z') {
|
||||
a = (char)(a - 'A' + 'a');
|
||||
}
|
||||
if (b >= 'A' && b <= 'Z') {
|
||||
b = (char)(b - 'A' + 'a');
|
||||
}
|
||||
if (a != b) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool classify(const char *name, PhotoKind *kind) {
|
||||
if (ends_with_ci(name, ".jpg") || ends_with_ci(name, ".jpeg")) {
|
||||
*kind = PHOTO_JPG;
|
||||
return true;
|
||||
}
|
||||
if (ends_with_ci(name, ".png")) {
|
||||
*kind = PHOTO_PNG;
|
||||
return true;
|
||||
}
|
||||
if (ends_with_ci(name, ".gif")) {
|
||||
*kind = PHOTO_GIF;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static void add_file(const char *dir, const char *name, PhotoKind kind) {
|
||||
if (g_count >= PHOTO_MAX) {
|
||||
return;
|
||||
}
|
||||
PhotoEntry *e = &g_list[g_count];
|
||||
if (dir[0] == '/' && dir[1] == '\0') {
|
||||
snprintf(e->path, sizeof(e->path), "/%s", name);
|
||||
} else {
|
||||
snprintf(e->path, sizeof(e->path), "%s/%s", dir, name);
|
||||
}
|
||||
e->kind = kind;
|
||||
g_count++;
|
||||
}
|
||||
|
||||
static void scan_dir(const char *dir) {
|
||||
File root = SD.open(dir);
|
||||
if (!root || !root.isDirectory()) {
|
||||
if (root) {
|
||||
root.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
File f = root.openNextFile();
|
||||
while (f) {
|
||||
if (!f.isDirectory()) {
|
||||
const char *name = f.name();
|
||||
// SD lib may return full path; keep basename
|
||||
const char *base = strrchr(name, '/');
|
||||
base = base ? base + 1 : name;
|
||||
PhotoKind kind;
|
||||
if (classify(base, &kind)) {
|
||||
add_file(dir, base, kind);
|
||||
}
|
||||
}
|
||||
f = root.openNextFile();
|
||||
}
|
||||
root.close();
|
||||
}
|
||||
|
||||
uint16_t photo_list_count() { return g_count; }
|
||||
|
||||
const PhotoEntry *photo_list_get(uint16_t index) {
|
||||
if (index >= g_count) {
|
||||
return nullptr;
|
||||
}
|
||||
return &g_list[index];
|
||||
}
|
||||
|
||||
uint16_t photo_list_scan() {
|
||||
g_count = 0;
|
||||
scan_dir("/photos");
|
||||
scan_dir("/");
|
||||
return g_count;
|
||||
}
|
||||
496
src/photo_view.cpp
Normal file
496
src/photo_view.cpp
Normal file
@@ -0,0 +1,496 @@
|
||||
#include "photo_view.h"
|
||||
#include "pins.h"
|
||||
|
||||
#include <AnimatedGIF.h>
|
||||
#include <Arduino.h>
|
||||
#include <JPEGDEC.h>
|
||||
#include <PNGdec.h>
|
||||
#include <SD.h>
|
||||
#include <string.h>
|
||||
|
||||
// Half-res canvas (fits ESP32 DRAM). UI shows it at 2x -> 320x240.
|
||||
static constexpr uint16_t CANVAS_W = UI_HOR_RES / 2;
|
||||
static constexpr uint16_t CANVAS_H = UI_VER_RES / 2;
|
||||
static constexpr size_t CANVAS_PIXELS = (size_t)CANVAS_W * (size_t)CANVAS_H;
|
||||
|
||||
static uint16_t g_pixels[CANVAS_PIXELS];
|
||||
static bool g_ready = false;
|
||||
static lv_image_dsc_t g_dsc;
|
||||
static uint16_t g_img_w = 0;
|
||||
static uint16_t g_img_h = 0;
|
||||
|
||||
static JPEGDEC *g_jpeg = nullptr;
|
||||
static PNG *g_png = nullptr;
|
||||
static AnimatedGIF *g_gif = nullptr;
|
||||
|
||||
static File g_file;
|
||||
static bool g_gif_open = false;
|
||||
static uint32_t g_gif_next_ms = 0;
|
||||
static int g_off_x = 0;
|
||||
static int g_off_y = 0;
|
||||
static int g_src_w = 0;
|
||||
static int g_src_h = 0;
|
||||
static int g_dst_w = 0;
|
||||
static int g_dst_h = 0;
|
||||
|
||||
static void canvas_clear(uint16_t color) {
|
||||
for (size_t i = 0; i < CANVAS_PIXELS; i++) {
|
||||
g_pixels[i] = color;
|
||||
}
|
||||
}
|
||||
|
||||
static void canvas_sync_dsc() {
|
||||
g_dsc.header.magic = LV_IMAGE_HEADER_MAGIC;
|
||||
g_dsc.header.cf = LV_COLOR_FORMAT_RGB565;
|
||||
g_dsc.header.flags = 0;
|
||||
g_dsc.header.w = CANVAS_W;
|
||||
g_dsc.header.h = CANVAS_H;
|
||||
g_dsc.header.stride = CANVAS_W * 2;
|
||||
g_dsc.data_size = CANVAS_PIXELS * 2;
|
||||
g_dsc.data = (const uint8_t *)g_pixels;
|
||||
g_dsc.reserved = nullptr;
|
||||
}
|
||||
|
||||
static void free_jpeg() {
|
||||
if (g_jpeg) {
|
||||
delete g_jpeg;
|
||||
g_jpeg = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
static void free_png() {
|
||||
if (g_png) {
|
||||
delete g_png;
|
||||
g_png = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
static void gif_close_session() {
|
||||
if (g_gif_open && g_gif) {
|
||||
g_gif->close();
|
||||
g_gif_open = false;
|
||||
}
|
||||
if (g_gif) {
|
||||
delete g_gif;
|
||||
g_gif = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
static void fit_size(int nw, int nh, int *dw, int *dh) {
|
||||
*dw = nw;
|
||||
*dh = nh;
|
||||
if (*dw <= (int)CANVAS_W && *dh <= (int)CANVAS_H) {
|
||||
return;
|
||||
}
|
||||
const float sx = (float)CANVAS_W / (float)nw;
|
||||
const float sy = (float)CANVAS_H / (float)nh;
|
||||
const float s = sx < sy ? sx : sy;
|
||||
*dw = (int)(nw * s + 0.5f);
|
||||
*dh = (int)(nh * s + 0.5f);
|
||||
if (*dw < 1) {
|
||||
*dw = 1;
|
||||
}
|
||||
if (*dh < 1) {
|
||||
*dh = 1;
|
||||
}
|
||||
}
|
||||
|
||||
static int jpeg_scale_for(int w, int h) {
|
||||
if (w > CANVAS_W * 4 || h > CANVAS_H * 4) {
|
||||
return JPEG_SCALE_EIGHTH;
|
||||
}
|
||||
if (w > CANVAS_W * 2 || h > CANVAS_H * 2) {
|
||||
return JPEG_SCALE_QUARTER;
|
||||
}
|
||||
if (w > CANVAS_W || h > CANVAS_H) {
|
||||
return JPEG_SCALE_HALF;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void *jpeg_open(const char *filename, int32_t *size) {
|
||||
g_file = SD.open(filename);
|
||||
if (!g_file) {
|
||||
return nullptr;
|
||||
}
|
||||
*size = (int32_t)g_file.size();
|
||||
return &g_file;
|
||||
}
|
||||
|
||||
static void jpeg_close(void *handle) {
|
||||
if (handle) {
|
||||
((File *)handle)->close();
|
||||
}
|
||||
}
|
||||
|
||||
static int32_t jpeg_read(JPEGFILE *handle, uint8_t *buffer, int32_t length) {
|
||||
if (!handle || !handle->fHandle) {
|
||||
return 0;
|
||||
}
|
||||
return (int32_t)((File *)handle->fHandle)->read(buffer, length);
|
||||
}
|
||||
|
||||
static int32_t jpeg_seek(JPEGFILE *handle, int32_t position) {
|
||||
if (!handle || !handle->fHandle) {
|
||||
return 0;
|
||||
}
|
||||
return ((File *)handle->fHandle)->seek(position) ? position : 0;
|
||||
}
|
||||
|
||||
static int jpeg_draw(JPEGDRAW *p) {
|
||||
if (!p || !p->pPixels) {
|
||||
return 0;
|
||||
}
|
||||
for (int row = 0; row < p->iHeight; row++) {
|
||||
const int y = p->y + row;
|
||||
if (y < 0 || y >= (int)CANVAS_H) {
|
||||
continue;
|
||||
}
|
||||
const uint16_t *src = p->pPixels + row * p->iWidth;
|
||||
for (int col = 0; col < p->iWidth; col++) {
|
||||
const int x = p->x + col;
|
||||
if (x < 0 || x >= (int)CANVAS_W) {
|
||||
continue;
|
||||
}
|
||||
g_pixels[(size_t)y * CANVAS_W + (size_t)x] = src[col];
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void *png_open(const char *filename, int32_t *size) {
|
||||
g_file = SD.open(filename);
|
||||
if (!g_file) {
|
||||
return nullptr;
|
||||
}
|
||||
*size = (int32_t)g_file.size();
|
||||
return &g_file;
|
||||
}
|
||||
|
||||
static void png_close(void *handle) {
|
||||
if (handle) {
|
||||
((File *)handle)->close();
|
||||
}
|
||||
}
|
||||
|
||||
static int32_t png_read(PNGFILE *handle, uint8_t *buffer, int32_t length) {
|
||||
if (!handle || !handle->fHandle) {
|
||||
return 0;
|
||||
}
|
||||
return (int32_t)((File *)handle->fHandle)->read(buffer, length);
|
||||
}
|
||||
|
||||
static int32_t png_seek(PNGFILE *handle, int32_t position) {
|
||||
if (!handle || !handle->fHandle) {
|
||||
return 0;
|
||||
}
|
||||
return ((File *)handle->fHandle)->seek(position) ? position : 0;
|
||||
}
|
||||
|
||||
static int png_draw(PNGDRAW *p) {
|
||||
if (!p || !g_png || g_src_w <= 0 || g_dst_w <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Line buffer for source width (cap to avoid stack blow)
|
||||
static uint16_t line[640];
|
||||
if (p->iWidth > 640) {
|
||||
return 0;
|
||||
}
|
||||
g_png->getLineAsRGB565(p, line, PNG_RGB565_LITTLE_ENDIAN, 0x0000);
|
||||
|
||||
// Nearest-neighbor: which dest rows map from this source row?
|
||||
for (int dy = 0; dy < g_dst_h; dy++) {
|
||||
const int sy = (dy * g_src_h) / g_dst_h;
|
||||
if (sy != p->y) {
|
||||
continue;
|
||||
}
|
||||
const int y = g_off_y + dy;
|
||||
if (y < 0 || y >= (int)CANVAS_H) {
|
||||
continue;
|
||||
}
|
||||
for (int dx = 0; dx < g_dst_w; dx++) {
|
||||
const int sx = (dx * g_src_w) / g_dst_w;
|
||||
if (sx < 0 || sx >= p->iWidth) {
|
||||
continue;
|
||||
}
|
||||
const int x = g_off_x + dx;
|
||||
if (x < 0 || x >= (int)CANVAS_W) {
|
||||
continue;
|
||||
}
|
||||
g_pixels[(size_t)y * CANVAS_W + (size_t)x] = line[sx];
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void *gif_open(const char *fname, int32_t *pSize) {
|
||||
g_file = SD.open(fname);
|
||||
if (!g_file) {
|
||||
return nullptr;
|
||||
}
|
||||
*pSize = (int32_t)g_file.size();
|
||||
return &g_file;
|
||||
}
|
||||
|
||||
static void gif_close(void *pHandle) {
|
||||
if (pHandle) {
|
||||
((File *)pHandle)->close();
|
||||
}
|
||||
}
|
||||
|
||||
static int32_t gif_read(GIFFILE *pFile, uint8_t *pBuf, int32_t iLen) {
|
||||
if (!pFile || !pFile->fHandle) {
|
||||
return 0;
|
||||
}
|
||||
int32_t n = (int32_t)((File *)pFile->fHandle)->read(pBuf, iLen);
|
||||
pFile->iPos = (int32_t)((File *)pFile->fHandle)->position();
|
||||
return n;
|
||||
}
|
||||
|
||||
static int32_t gif_seek(GIFFILE *pFile, int32_t iPosition) {
|
||||
if (!pFile || !pFile->fHandle) {
|
||||
return 0;
|
||||
}
|
||||
((File *)pFile->fHandle)->seek(iPosition);
|
||||
pFile->iPos = (int32_t)((File *)pFile->fHandle)->position();
|
||||
return pFile->iPos;
|
||||
}
|
||||
|
||||
static void gif_draw(GIFDRAW *pDraw) {
|
||||
if (!pDraw || g_src_w <= 0 || g_dst_w <= 0) {
|
||||
return;
|
||||
}
|
||||
// Map source frame lines into scaled dest canvas
|
||||
const int src_y = pDraw->iY + pDraw->y;
|
||||
for (int dy = 0; dy < g_dst_h; dy++) {
|
||||
const int sy = (dy * g_src_h) / g_dst_h;
|
||||
if (sy != src_y) {
|
||||
continue;
|
||||
}
|
||||
const int y = g_off_y + dy;
|
||||
if (y < 0 || y >= (int)CANVAS_H) {
|
||||
continue;
|
||||
}
|
||||
for (int dx = 0; dx < g_dst_w; dx++) {
|
||||
const int sx = (dx * g_src_w) / g_dst_w;
|
||||
const int lx = sx - pDraw->iX;
|
||||
if (lx < 0 || lx >= pDraw->iWidth) {
|
||||
continue;
|
||||
}
|
||||
const uint8_t idx = pDraw->pPixels[lx];
|
||||
if (pDraw->ucHasTransparency && idx == pDraw->ucTransparent) {
|
||||
continue;
|
||||
}
|
||||
const int x = g_off_x + dx;
|
||||
if (x < 0 || x >= (int)CANVAS_W) {
|
||||
continue;
|
||||
}
|
||||
g_pixels[(size_t)y * CANVAS_W + (size_t)x] = pDraw->pPalette[idx];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static bool load_jpeg(const char *path) {
|
||||
gif_close_session();
|
||||
free_png();
|
||||
free_jpeg();
|
||||
|
||||
g_jpeg = new JPEGDEC();
|
||||
if (!g_jpeg) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!g_jpeg->open(path, jpeg_open, jpeg_close, jpeg_read, jpeg_seek,
|
||||
jpeg_draw)) {
|
||||
Serial.printf("JPEG open fail: %s err=%d\n", path, g_jpeg->getLastError());
|
||||
free_jpeg();
|
||||
return false;
|
||||
}
|
||||
|
||||
const int nw = g_jpeg->getWidth();
|
||||
const int nh = g_jpeg->getHeight();
|
||||
const int scale = jpeg_scale_for(nw, nh);
|
||||
int dw = nw;
|
||||
int dh = nh;
|
||||
if (scale == JPEG_SCALE_HALF) {
|
||||
dw /= 2;
|
||||
dh /= 2;
|
||||
} else if (scale == JPEG_SCALE_QUARTER) {
|
||||
dw /= 4;
|
||||
dh /= 4;
|
||||
} else if (scale == JPEG_SCALE_EIGHTH) {
|
||||
dw /= 8;
|
||||
dh /= 8;
|
||||
}
|
||||
|
||||
g_img_w = (uint16_t)dw;
|
||||
g_img_h = (uint16_t)dh;
|
||||
const int xoff = ((int)CANVAS_W - dw) / 2;
|
||||
const int yoff = ((int)CANVAS_H - dh) / 2;
|
||||
|
||||
canvas_clear(0x0000);
|
||||
g_jpeg->setPixelType(RGB565_LITTLE_ENDIAN);
|
||||
const bool ok = g_jpeg->decode(xoff, yoff, scale);
|
||||
g_jpeg->close();
|
||||
free_jpeg();
|
||||
if (!ok) {
|
||||
Serial.printf("JPEG decode fail: %s\n", path);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
static bool load_png(const char *path) {
|
||||
gif_close_session();
|
||||
free_jpeg();
|
||||
free_png();
|
||||
|
||||
g_png = new PNG();
|
||||
if (!g_png) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const int rc =
|
||||
g_png->open(path, png_open, png_close, png_read, png_seek, png_draw);
|
||||
if (rc != PNG_SUCCESS) {
|
||||
Serial.printf("PNG open fail: %s rc=%d\n", path, rc);
|
||||
free_png();
|
||||
return false;
|
||||
}
|
||||
|
||||
g_src_w = g_png->getWidth();
|
||||
g_src_h = g_png->getHeight();
|
||||
if (g_src_w <= 0 || g_src_h <= 0 || g_src_w > 640) {
|
||||
Serial.printf("PNG bad size %dx%d\n", g_src_w, g_src_h);
|
||||
g_png->close();
|
||||
free_png();
|
||||
return false;
|
||||
}
|
||||
|
||||
fit_size(g_src_w, g_src_h, &g_dst_w, &g_dst_h);
|
||||
g_img_w = (uint16_t)g_dst_w;
|
||||
g_img_h = (uint16_t)g_dst_h;
|
||||
g_off_x = ((int)CANVAS_W - g_dst_w) / 2;
|
||||
g_off_y = ((int)CANVAS_H - g_dst_h) / 2;
|
||||
|
||||
canvas_clear(0x0000);
|
||||
const int drc = g_png->decode(nullptr, 0);
|
||||
g_png->close();
|
||||
free_png();
|
||||
if (drc != PNG_SUCCESS) {
|
||||
Serial.printf("PNG decode fail: %s rc=%d\n", path, drc);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool load_gif(const char *path) {
|
||||
free_jpeg();
|
||||
free_png();
|
||||
gif_close_session();
|
||||
|
||||
g_gif = new AnimatedGIF();
|
||||
if (!g_gif) {
|
||||
return false;
|
||||
}
|
||||
|
||||
g_gif->begin(GIF_PALETTE_RGB565_LE);
|
||||
if (!g_gif->open(path, gif_open, gif_close, gif_read, gif_seek, gif_draw)) {
|
||||
Serial.printf("GIF open fail: %s\n", path);
|
||||
gif_close_session();
|
||||
return false;
|
||||
}
|
||||
|
||||
g_src_w = g_gif->getCanvasWidth();
|
||||
g_src_h = g_gif->getCanvasHeight();
|
||||
if (g_src_w <= 0 || g_src_h <= 0) {
|
||||
gif_close_session();
|
||||
return false;
|
||||
}
|
||||
|
||||
fit_size(g_src_w, g_src_h, &g_dst_w, &g_dst_h);
|
||||
g_img_w = (uint16_t)g_dst_w;
|
||||
g_img_h = (uint16_t)g_dst_h;
|
||||
g_off_x = ((int)CANVAS_W - g_dst_w) / 2;
|
||||
g_off_y = ((int)CANVAS_H - g_dst_h) / 2;
|
||||
g_gif_open = true;
|
||||
|
||||
canvas_clear(0x0000);
|
||||
int delay_ms = 0;
|
||||
g_gif->playFrame(true, &delay_ms);
|
||||
if (delay_ms < 20) {
|
||||
delay_ms = 100;
|
||||
}
|
||||
g_gif_next_ms = millis() + (uint32_t)delay_ms;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool photo_view_init() {
|
||||
if (g_ready) {
|
||||
return true;
|
||||
}
|
||||
canvas_clear(0x0000);
|
||||
canvas_sync_dsc();
|
||||
g_ready = true;
|
||||
Serial.printf("photo_view: canvas %ux%u (%u bytes), free heap %u\n",
|
||||
(unsigned)CANVAS_W, (unsigned)CANVAS_H,
|
||||
(unsigned)(CANVAS_PIXELS * 2), (unsigned)ESP.getFreeHeap());
|
||||
return true;
|
||||
}
|
||||
|
||||
void photo_view_deinit() {
|
||||
gif_close_session();
|
||||
free_jpeg();
|
||||
free_png();
|
||||
g_ready = false;
|
||||
g_dsc.data = nullptr;
|
||||
}
|
||||
|
||||
bool photo_view_show(const PhotoEntry *entry) {
|
||||
if (!entry || !photo_view_init()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ok = false;
|
||||
switch (entry->kind) {
|
||||
case PHOTO_JPG:
|
||||
ok = load_jpeg(entry->path);
|
||||
break;
|
||||
case PHOTO_PNG:
|
||||
ok = load_png(entry->path);
|
||||
break;
|
||||
case PHOTO_GIF:
|
||||
ok = load_gif(entry->path);
|
||||
break;
|
||||
}
|
||||
canvas_sync_dsc();
|
||||
return ok;
|
||||
}
|
||||
|
||||
const lv_image_dsc_t *photo_view_dsc() {
|
||||
return g_ready ? &g_dsc : nullptr;
|
||||
}
|
||||
|
||||
uint16_t photo_view_canvas_w() { return CANVAS_W; }
|
||||
uint16_t photo_view_canvas_h() { return CANVAS_H; }
|
||||
uint16_t photo_view_img_w() { return g_img_w; }
|
||||
uint16_t photo_view_img_h() { return g_img_h; }
|
||||
|
||||
bool photo_view_is_gif() { return g_gif_open; }
|
||||
|
||||
bool photo_view_gif_tick() {
|
||||
if (!g_gif_open || !g_gif) {
|
||||
return false;
|
||||
}
|
||||
const uint32_t now = millis();
|
||||
if ((int32_t)(now - g_gif_next_ms) < 0) {
|
||||
return false;
|
||||
}
|
||||
int delay_ms = 0;
|
||||
g_gif->playFrame(true, &delay_ms);
|
||||
if (delay_ms < 20) {
|
||||
delay_ms = 100;
|
||||
}
|
||||
g_gif_next_ms = now + (uint32_t)delay_ms;
|
||||
return true;
|
||||
}
|
||||
136
src/sd_info.cpp
Normal file
136
src/sd_info.cpp
Normal file
@@ -0,0 +1,136 @@
|
||||
#include "sd_info.h"
|
||||
#include "pins.h"
|
||||
|
||||
#include <FS.h>
|
||||
#include <SD.h>
|
||||
#include <SPI.h>
|
||||
|
||||
static SPIClass sd_spi(VSPI);
|
||||
static bool sd_ready = false;
|
||||
|
||||
static const char *card_type_name(uint8_t t) {
|
||||
switch (t) {
|
||||
case CARD_MMC: return "MMC";
|
||||
case CARD_SD: return "SDSC";
|
||||
case CARD_SDHC: return "SDHC";
|
||||
case CARD_NONE: return "NONE";
|
||||
default: return "UNKNOWN";
|
||||
}
|
||||
}
|
||||
|
||||
static const char *detect_filesystem() {
|
||||
File root = SD.open("/");
|
||||
if (!root) {
|
||||
return "-";
|
||||
}
|
||||
root.close();
|
||||
|
||||
uint64_t total = SD.totalBytes();
|
||||
if (total == 0) {
|
||||
return "FAT";
|
||||
}
|
||||
if (total > 32ULL * 1024ULL * 1024ULL) {
|
||||
return "FAT32";
|
||||
}
|
||||
if (total > 2ULL * 1024ULL * 1024ULL) {
|
||||
return "FAT16";
|
||||
}
|
||||
return "FAT12/16";
|
||||
}
|
||||
|
||||
static uint32_t count_root_entries() {
|
||||
File root = SD.open("/");
|
||||
if (!root || !root.isDirectory()) {
|
||||
return 0;
|
||||
}
|
||||
uint32_t n = 0;
|
||||
File f = root.openNextFile();
|
||||
while (f) {
|
||||
n++;
|
||||
f = root.openNextFile();
|
||||
}
|
||||
root.close();
|
||||
return n;
|
||||
}
|
||||
|
||||
bool sd_init() {
|
||||
if (sd_ready) {
|
||||
return true;
|
||||
}
|
||||
|
||||
pinMode(SD_CS, OUTPUT);
|
||||
digitalWrite(SD_CS, HIGH);
|
||||
pinMode(CYD_TOUCH_CS, OUTPUT);
|
||||
digitalWrite(CYD_TOUCH_CS, HIGH);
|
||||
|
||||
sd_spi.begin(SD_SCK, SD_MISO, SD_MOSI, SD_CS);
|
||||
|
||||
if (!SD.begin(SD_CS, sd_spi, 25000000)) {
|
||||
sd_ready = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
sd_ready = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void sd_deinit() {
|
||||
if (sd_ready) {
|
||||
SD.end();
|
||||
sd_ready = false;
|
||||
}
|
||||
}
|
||||
|
||||
SdCardInfo sd_read_info() {
|
||||
SdCardInfo info{};
|
||||
info.mounted = false;
|
||||
info.present = false;
|
||||
info.type = "NONE";
|
||||
info.filesystem = "-";
|
||||
info.card_bytes = 0;
|
||||
info.total_bytes = 0;
|
||||
info.used_bytes = 0;
|
||||
info.free_bytes = 0;
|
||||
info.root_files = 0;
|
||||
|
||||
if (!sd_ready && !sd_init()) {
|
||||
info.error = "Mount failed";
|
||||
return info;
|
||||
}
|
||||
|
||||
uint8_t type = SD.cardType();
|
||||
info.type = card_type_name(type);
|
||||
|
||||
if (type == CARD_NONE) {
|
||||
info.error = "No card";
|
||||
info.present = false;
|
||||
return info;
|
||||
}
|
||||
|
||||
info.present = true;
|
||||
info.mounted = true;
|
||||
info.card_bytes = SD.cardSize();
|
||||
info.total_bytes = SD.totalBytes();
|
||||
info.used_bytes = SD.usedBytes();
|
||||
info.free_bytes =
|
||||
(info.total_bytes > info.used_bytes) ? (info.total_bytes - info.used_bytes) : 0;
|
||||
info.filesystem = detect_filesystem();
|
||||
info.root_files = count_root_entries();
|
||||
info.error = "";
|
||||
return info;
|
||||
}
|
||||
|
||||
String format_bytes(uint64_t bytes) {
|
||||
const double b = static_cast<double>(bytes);
|
||||
char buf[48];
|
||||
if (bytes >= (1ULL << 30)) {
|
||||
snprintf(buf, sizeof(buf), "%.2f GB", b / (1024.0 * 1024.0 * 1024.0));
|
||||
} else if (bytes >= (1ULL << 20)) {
|
||||
snprintf(buf, sizeof(buf), "%.2f MB", b / (1024.0 * 1024.0));
|
||||
} else if (bytes >= (1ULL << 10)) {
|
||||
snprintf(buf, sizeof(buf), "%.2f KB", b / 1024.0);
|
||||
} else {
|
||||
snprintf(buf, sizeof(buf), "%llu B", static_cast<unsigned long long>(bytes));
|
||||
}
|
||||
return String(buf);
|
||||
}
|
||||
121
src/sd_put.cpp
Normal file
121
src/sd_put.cpp
Normal file
@@ -0,0 +1,121 @@
|
||||
#include "sd_put.h"
|
||||
#include "sd_info.h"
|
||||
|
||||
#include <SD.h>
|
||||
|
||||
static String read_line_timeout(uint32_t timeout_ms) {
|
||||
String line;
|
||||
const uint32_t start = millis();
|
||||
while (millis() - start < timeout_ms) {
|
||||
while (Serial.available()) {
|
||||
const char c = (char)Serial.read();
|
||||
if (c == '\n' || c == '\r') {
|
||||
if (line.length()) {
|
||||
return line;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
line += c;
|
||||
if (line.length() > 200) {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
delay(1);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
bool sd_put_poll() {
|
||||
if (!Serial.available()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 只在行首是 #PUT 时进入,避免吃掉普通日志
|
||||
if (Serial.peek() != '#') {
|
||||
// 丢弃非协议噪声到换行,防止堵死
|
||||
while (Serial.available() && Serial.peek() != '#') {
|
||||
const int c = Serial.read();
|
||||
if (c == '\n' || c == '\r') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const String line = read_line_timeout(500);
|
||||
if (!line.startsWith("#PUT ")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// #PUT /paomo.mp3 4143009
|
||||
int sp1 = line.indexOf(' ', 5);
|
||||
if (sp1 < 0) {
|
||||
Serial.println("PUT_ERR bad args");
|
||||
return true;
|
||||
}
|
||||
const String path = line.substring(5, sp1);
|
||||
const uint32_t size = (uint32_t)line.substring(sp1 + 1).toInt();
|
||||
if (path.length() < 2 || path[0] != '/' || size == 0 || size > 64UL * 1024UL * 1024UL) {
|
||||
Serial.println("PUT_ERR bad path/size");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!sd_init()) {
|
||||
Serial.println("PUT_ERR sd");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (SD.exists(path.c_str())) {
|
||||
SD.remove(path.c_str());
|
||||
}
|
||||
|
||||
File f = SD.open(path.c_str(), FILE_WRITE);
|
||||
if (!f) {
|
||||
Serial.println("PUT_ERR open");
|
||||
return true;
|
||||
}
|
||||
|
||||
Serial.println("READY");
|
||||
Serial.flush();
|
||||
|
||||
// 窗口协议:每收满一块回 ACK,避免 RX 溢出
|
||||
constexpr size_t k_win = 4096;
|
||||
uint8_t buf[k_win];
|
||||
uint32_t got = 0;
|
||||
while (got < size) {
|
||||
const uint32_t need = size - got;
|
||||
const size_t chunk = need > k_win ? k_win : (size_t)need;
|
||||
size_t n = 0;
|
||||
const uint32_t wait_start = millis();
|
||||
while (n < chunk && millis() - wait_start < 30000) {
|
||||
if (!Serial.available()) {
|
||||
delay(1);
|
||||
continue;
|
||||
}
|
||||
Serial.setTimeout(50);
|
||||
const int r = Serial.readBytes(buf + n, chunk - n);
|
||||
if (r > 0) {
|
||||
n += (size_t)r;
|
||||
}
|
||||
}
|
||||
if (n == 0) {
|
||||
f.close();
|
||||
SD.remove(path.c_str());
|
||||
Serial.println("PUT_ERR timeout");
|
||||
return true;
|
||||
}
|
||||
if (f.write(buf, n) != n) {
|
||||
f.close();
|
||||
SD.remove(path.c_str());
|
||||
Serial.println("PUT_ERR write");
|
||||
return true;
|
||||
}
|
||||
got += n;
|
||||
Serial.printf("ACK %u\n", (unsigned)got);
|
||||
Serial.flush();
|
||||
}
|
||||
f.flush();
|
||||
f.close();
|
||||
Serial.println("DONE");
|
||||
return true;
|
||||
}
|
||||
125
src/touch.cpp
Normal file
125
src/touch.cpp
Normal file
@@ -0,0 +1,125 @@
|
||||
#include "touch.h"
|
||||
#include "pins.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
/**
|
||||
* SoftSPI XPT2046
|
||||
* BL↔TR 是矩形主对角线镜像(XY 对调),不是 180°(180° 会搞成 TL↔BR)。
|
||||
* 先按 rot2 得到旧 UI,再做对角修正,反变换喂给 LVGL 270。
|
||||
*/
|
||||
|
||||
int16_t g_dbg_phys_x = -1;
|
||||
int16_t g_dbg_phys_y = -1;
|
||||
int16_t g_dbg_logic_x = -1;
|
||||
int16_t g_dbg_logic_y = -1;
|
||||
bool g_dbg_touch_down = false;
|
||||
|
||||
static void soft_write(uint8_t data) {
|
||||
for (int i = 7; i >= 0; --i) {
|
||||
digitalWrite(CYD_TOUCH_MOSI, (data >> i) & 1);
|
||||
digitalWrite(CYD_TOUCH_CLK, HIGH);
|
||||
delayMicroseconds(2);
|
||||
digitalWrite(CYD_TOUCH_CLK, LOW);
|
||||
delayMicroseconds(2);
|
||||
}
|
||||
}
|
||||
|
||||
static uint16_t soft_read12() {
|
||||
uint16_t v = 0;
|
||||
digitalWrite(CYD_TOUCH_CLK, HIGH);
|
||||
delayMicroseconds(2);
|
||||
digitalWrite(CYD_TOUCH_CLK, LOW);
|
||||
delayMicroseconds(2);
|
||||
for (int i = 0; i < 12; ++i) {
|
||||
digitalWrite(CYD_TOUCH_CLK, HIGH);
|
||||
delayMicroseconds(2);
|
||||
v = (uint16_t)((v << 1) | (digitalRead(CYD_TOUCH_MISO) ? 1u : 0u));
|
||||
digitalWrite(CYD_TOUCH_CLK, LOW);
|
||||
delayMicroseconds(2);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
static uint16_t xpt_read(uint8_t cmd) {
|
||||
digitalWrite(CYD_TOUCH_CS, LOW);
|
||||
soft_write(cmd);
|
||||
uint16_t v = soft_read12();
|
||||
digitalWrite(CYD_TOUCH_CS, HIGH);
|
||||
return v;
|
||||
}
|
||||
|
||||
static int16_t clampi(int32_t v, int32_t lo, int32_t hi) {
|
||||
if (v < lo) {
|
||||
return (int16_t)lo;
|
||||
}
|
||||
if (v > hi) {
|
||||
return (int16_t)hi;
|
||||
}
|
||||
return (int16_t)v;
|
||||
}
|
||||
|
||||
void touch_init() {
|
||||
pinMode(CYD_TOUCH_CS, OUTPUT);
|
||||
pinMode(CYD_TOUCH_CLK, OUTPUT);
|
||||
pinMode(CYD_TOUCH_MOSI, OUTPUT);
|
||||
pinMode(CYD_TOUCH_MISO, INPUT);
|
||||
pinMode(CYD_TOUCH_IRQ, INPUT);
|
||||
digitalWrite(CYD_TOUCH_CS, HIGH);
|
||||
digitalWrite(CYD_TOUCH_CLK, LOW);
|
||||
Serial.println("Touch: SoftSPI + diagonal fix (BL/TR)");
|
||||
}
|
||||
|
||||
void touch_read_cb(lv_indev_t * /*indev*/, lv_indev_data_t *data) {
|
||||
uint16_t adc_x = xpt_read(0x90);
|
||||
uint16_t adc_y = xpt_read(0xD0);
|
||||
adc_x = (uint16_t)((adc_x + xpt_read(0x90)) / 2);
|
||||
adc_y = (uint16_t)((adc_y + xpt_read(0xD0)) / 2);
|
||||
|
||||
const bool irq = (digitalRead(CYD_TOUCH_IRQ) == LOW);
|
||||
const bool in_range =
|
||||
(adc_x > 80 && adc_x < 4000 && adc_y > 80 && adc_y < 4000);
|
||||
|
||||
if (!in_range || (!irq && (adc_x < 200 || adc_y < 200))) {
|
||||
data->state = LV_INDEV_STATE_RELEASED;
|
||||
g_dbg_touch_down = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const int16_t p_x = (int16_t)adc_x;
|
||||
const int16_t p_y = (int16_t)(4095 - adc_y);
|
||||
|
||||
int16_t x = map(p_x, 200, 3700, 1, SCREEN_WIDTH);
|
||||
int16_t y = map(p_y, 240, 3800, 1, SCREEN_HEIGHT);
|
||||
x = clampi(x, 0, SCREEN_WIDTH - 1);
|
||||
y = clampi(y, 0, SCREEN_HEIGHT - 1);
|
||||
|
||||
// 旧 UI(LVGL270):(y, 239-x) — 该空间里 BL↔TR,做主对角线镜像
|
||||
// (ux,uy) -> (uy*(319/239), ux*(239/319))
|
||||
const int32_t old_ux = y;
|
||||
const int32_t old_uy = (SCREEN_WIDTH - 1) - x;
|
||||
int16_t ui_x = clampi((old_uy * (UI_HOR_RES - 1)) / (UI_VER_RES - 1), 0,
|
||||
UI_HOR_RES - 1);
|
||||
int16_t ui_y = clampi((old_ux * (UI_VER_RES - 1)) / (UI_HOR_RES - 1), 0,
|
||||
UI_VER_RES - 1);
|
||||
|
||||
// 反变换喂 LVGL,使最终落在 (ui_x, ui_y)
|
||||
const int16_t feed_x = (int16_t)((SCREEN_WIDTH - 1) - ui_y);
|
||||
const int16_t feed_y = ui_x;
|
||||
|
||||
g_dbg_phys_x = x;
|
||||
g_dbg_phys_y = y;
|
||||
g_dbg_logic_x = ui_x;
|
||||
g_dbg_logic_y = ui_y;
|
||||
g_dbg_touch_down = true;
|
||||
|
||||
static uint32_t last_log = 0;
|
||||
if (millis() - last_log > 80) {
|
||||
last_log = millis();
|
||||
Serial.printf("Touch UI(%d,%d)\n", ui_x, ui_y);
|
||||
}
|
||||
|
||||
data->point.x = feed_x;
|
||||
data->point.y = feed_y;
|
||||
data->state = LV_INDEV_STATE_PRESSED;
|
||||
}
|
||||
296
src/ui.cpp
Normal file
296
src/ui.cpp
Normal file
@@ -0,0 +1,296 @@
|
||||
#include "ui.h"
|
||||
#include "photo_list.h"
|
||||
#include "photo_view.h"
|
||||
#include "pins.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <lvgl.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
static constexpr uint32_t COL_BG = 0x0A1016;
|
||||
static constexpr uint32_t COL_INK = 0xEAF0F6;
|
||||
static constexpr uint32_t COL_MUTED = 0x7A8B9C;
|
||||
static constexpr uint32_t COL_OK = 0x3DDC97;
|
||||
static constexpr uint32_t COL_BAD = 0xE85D4C;
|
||||
static constexpr uint32_t COL_ACCENT = 0x4FC3F7;
|
||||
static constexpr uint32_t COL_BTN = 0x243140;
|
||||
static constexpr uint32_t COL_BTN_HI = 0x1A4A6E;
|
||||
|
||||
static lv_obj_t *lbl_title;
|
||||
static lv_obj_t *lbl_chip;
|
||||
static lv_obj_t *lbl_index;
|
||||
static lv_obj_t *lbl_name;
|
||||
static lv_obj_t *lbl_hint;
|
||||
static lv_obj_t *img_photo;
|
||||
static lv_obj_t *btn_prev;
|
||||
static lv_obj_t *btn_next;
|
||||
|
||||
static int16_t g_index = -1;
|
||||
static char g_index_text[24] = "0/0";
|
||||
static char g_name_text[48] = "-";
|
||||
|
||||
static const char *basename_of(const char *path) {
|
||||
const char *slash = strrchr(path, '/');
|
||||
return slash ? slash + 1 : path;
|
||||
}
|
||||
|
||||
static void style_btn(lv_obj_t *btn, uint32_t bg) {
|
||||
lv_obj_set_style_radius(btn, 8, 0);
|
||||
lv_obj_set_style_bg_color(btn, lv_color_hex(bg), 0);
|
||||
lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0);
|
||||
lv_obj_set_style_shadow_width(btn, 0, 0);
|
||||
lv_obj_set_style_border_width(btn, 0, 0);
|
||||
lv_obj_set_style_pad_all(btn, 0, 0);
|
||||
}
|
||||
|
||||
static void refresh_labels() {
|
||||
const uint16_t n = photo_list_count();
|
||||
if (n == 0 || g_index < 0) {
|
||||
snprintf(g_index_text, sizeof(g_index_text), "0/0");
|
||||
snprintf(g_name_text, sizeof(g_name_text), "No photos");
|
||||
} else {
|
||||
snprintf(g_index_text, sizeof(g_index_text), "%d/%u", (int)(g_index + 1),
|
||||
(unsigned)n);
|
||||
const PhotoEntry *e = photo_list_get((uint16_t)g_index);
|
||||
snprintf(g_name_text, sizeof(g_name_text), "%s",
|
||||
e ? basename_of(e->path) : "-");
|
||||
}
|
||||
if (lbl_index) {
|
||||
lv_label_set_text(lbl_index, g_index_text);
|
||||
}
|
||||
if (lbl_name) {
|
||||
lv_label_set_text(lbl_name, g_name_text);
|
||||
}
|
||||
}
|
||||
|
||||
static void show_current() {
|
||||
const uint16_t n = photo_list_count();
|
||||
if (n == 0) {
|
||||
g_index = -1;
|
||||
if (img_photo) {
|
||||
lv_image_set_src(img_photo, nullptr);
|
||||
}
|
||||
if (lbl_hint) {
|
||||
lv_label_set_text(lbl_hint, "Put jpg/png/gif in / or /photos");
|
||||
}
|
||||
refresh_labels();
|
||||
return;
|
||||
}
|
||||
|
||||
if (g_index < 0 || g_index >= (int16_t)n) {
|
||||
g_index = 0;
|
||||
}
|
||||
|
||||
const PhotoEntry *e = photo_list_get((uint16_t)g_index);
|
||||
const bool ok = e && photo_view_show(e);
|
||||
if (img_photo) {
|
||||
const lv_image_dsc_t *dsc = photo_view_dsc();
|
||||
// Force refresh even when dsc pointer is unchanged
|
||||
lv_image_set_src(img_photo, nullptr);
|
||||
if (ok && dsc) {
|
||||
lv_image_set_src(img_photo, dsc);
|
||||
}
|
||||
lv_obj_invalidate(img_photo);
|
||||
}
|
||||
if (lbl_hint) {
|
||||
if (!ok) {
|
||||
lv_label_set_text(lbl_hint, "Decode failed (size/format?)");
|
||||
} else if (photo_view_is_gif()) {
|
||||
lv_label_set_text(lbl_hint, "GIF | tap L/R to switch");
|
||||
} else {
|
||||
lv_label_set_text(lbl_hint, "Tap left=prev right=next");
|
||||
}
|
||||
}
|
||||
refresh_labels();
|
||||
}
|
||||
|
||||
static void go_delta(int delta) {
|
||||
const uint16_t n = photo_list_count();
|
||||
if (n == 0) {
|
||||
return;
|
||||
}
|
||||
int16_t next = (int16_t)(g_index + delta);
|
||||
while (next < 0) {
|
||||
next = (int16_t)(next + (int16_t)n);
|
||||
}
|
||||
while (next >= (int16_t)n) {
|
||||
next = (int16_t)(next - (int16_t)n);
|
||||
}
|
||||
if (next == g_index && n > 1) {
|
||||
// still reload if single? skip
|
||||
}
|
||||
g_index = next;
|
||||
show_current();
|
||||
}
|
||||
|
||||
static void on_prev(lv_event_t *e) {
|
||||
LV_UNUSED(e);
|
||||
go_delta(-1);
|
||||
}
|
||||
|
||||
static void on_next(lv_event_t *e) {
|
||||
LV_UNUSED(e);
|
||||
go_delta(1);
|
||||
}
|
||||
|
||||
static void on_tap_zone(lv_event_t *e) {
|
||||
lv_indev_t *indev = lv_indev_active();
|
||||
if (!indev) {
|
||||
return;
|
||||
}
|
||||
lv_point_t p;
|
||||
lv_indev_get_point(indev, &p);
|
||||
if (p.x < (UI_HOR_RES / 2)) {
|
||||
go_delta(-1);
|
||||
} else {
|
||||
go_delta(1);
|
||||
}
|
||||
}
|
||||
|
||||
void ui_reload_album() {
|
||||
const uint16_t n = photo_list_scan();
|
||||
Serial.printf("Photos found: %u\n", (unsigned)n);
|
||||
for (uint16_t i = 0; i < n; i++) {
|
||||
const PhotoEntry *e = photo_list_get(i);
|
||||
if (e) {
|
||||
Serial.printf(" [%u] %s\n", (unsigned)i, e->path);
|
||||
}
|
||||
}
|
||||
if (n == 0) {
|
||||
g_index = -1;
|
||||
} else if (g_index < 0 || g_index >= (int16_t)n) {
|
||||
g_index = 0;
|
||||
}
|
||||
show_current();
|
||||
}
|
||||
|
||||
void ui_create() {
|
||||
photo_view_init();
|
||||
|
||||
lv_obj_t *scr = lv_screen_active();
|
||||
lv_obj_set_style_bg_color(scr, lv_color_hex(COL_BG), 0);
|
||||
lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0);
|
||||
lv_obj_clear_flag(scr, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
// Full-screen tap zones (behind chrome)
|
||||
lv_obj_t *tap = lv_obj_create(scr);
|
||||
lv_obj_set_size(tap, UI_HOR_RES, UI_VER_RES);
|
||||
lv_obj_set_pos(tap, 0, 0);
|
||||
lv_obj_set_style_bg_opa(tap, LV_OPA_TRANSP, 0);
|
||||
lv_obj_set_style_border_width(tap, 0, 0);
|
||||
lv_obj_set_style_pad_all(tap, 0, 0);
|
||||
lv_obj_clear_flag(tap, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_add_flag(tap, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_add_event_cb(tap, on_tap_zone, LV_EVENT_CLICKED, nullptr);
|
||||
|
||||
img_photo = lv_image_create(scr);
|
||||
lv_obj_set_size(img_photo, photo_view_canvas_w(), photo_view_canvas_h());
|
||||
lv_image_set_scale(img_photo, 512); // 256=1x, 512=2x -> full 320x240
|
||||
lv_obj_align(img_photo, LV_ALIGN_CENTER, 0, 0);
|
||||
lv_obj_clear_flag(img_photo, LV_OBJ_FLAG_CLICKABLE);
|
||||
|
||||
// Top bar
|
||||
lv_obj_t *top = lv_obj_create(scr);
|
||||
lv_obj_set_size(top, UI_HOR_RES, 28);
|
||||
lv_obj_set_pos(top, 0, 0);
|
||||
lv_obj_set_style_bg_color(top, lv_color_hex(COL_BG), 0);
|
||||
lv_obj_set_style_bg_opa(top, LV_OPA_80, 0);
|
||||
lv_obj_set_style_border_width(top, 0, 0);
|
||||
lv_obj_set_style_pad_all(top, 0, 0);
|
||||
lv_obj_set_style_radius(top, 0, 0);
|
||||
lv_obj_clear_flag(top, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_clear_flag(top, LV_OBJ_FLAG_CLICKABLE);
|
||||
|
||||
lbl_title = lv_label_create(top);
|
||||
lv_label_set_text(lbl_title, "ALBUM");
|
||||
lv_obj_set_style_text_font(lbl_title, &lv_font_montserrat_16, 0);
|
||||
lv_obj_set_style_text_color(lbl_title, lv_color_hex(COL_ACCENT), 0);
|
||||
lv_obj_set_pos(lbl_title, 8, 5);
|
||||
|
||||
lbl_index = lv_label_create(top);
|
||||
lv_label_set_text(lbl_index, "0/0");
|
||||
lv_obj_set_style_text_font(lbl_index, &lv_font_montserrat_14, 0);
|
||||
lv_obj_set_style_text_color(lbl_index, lv_color_hex(COL_INK), 0);
|
||||
lv_obj_align(lbl_index, LV_ALIGN_TOP_MID, 0, 6);
|
||||
|
||||
lbl_chip = lv_label_create(top);
|
||||
lv_label_set_text(lbl_chip, "-");
|
||||
lv_obj_set_style_text_font(lbl_chip, &lv_font_montserrat_14, 0);
|
||||
lv_obj_set_style_text_color(lbl_chip, lv_color_hex(COL_MUTED), 0);
|
||||
lv_obj_align(lbl_chip, LV_ALIGN_TOP_RIGHT, -8, 6);
|
||||
|
||||
// Bottom bar
|
||||
lv_obj_t *bot = lv_obj_create(scr);
|
||||
lv_obj_set_size(bot, UI_HOR_RES, 40);
|
||||
lv_obj_set_pos(bot, 0, UI_VER_RES - 40);
|
||||
lv_obj_set_style_bg_color(bot, lv_color_hex(COL_BG), 0);
|
||||
lv_obj_set_style_bg_opa(bot, LV_OPA_80, 0);
|
||||
lv_obj_set_style_border_width(bot, 0, 0);
|
||||
lv_obj_set_style_pad_all(bot, 0, 0);
|
||||
lv_obj_set_style_radius(bot, 0, 0);
|
||||
lv_obj_clear_flag(bot, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
btn_prev = lv_button_create(bot);
|
||||
lv_obj_set_pos(btn_prev, 8, 6);
|
||||
lv_obj_set_size(btn_prev, 56, 28);
|
||||
lv_obj_add_flag(btn_prev, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_add_event_cb(btn_prev, on_prev, LV_EVENT_CLICKED, nullptr);
|
||||
style_btn(btn_prev, COL_BTN);
|
||||
lv_obj_t *lp = lv_label_create(btn_prev);
|
||||
lv_label_set_text(lp, "<");
|
||||
lv_obj_set_style_text_font(lp, &lv_font_montserrat_16, 0);
|
||||
lv_obj_set_style_text_color(lp, lv_color_hex(COL_INK), 0);
|
||||
lv_obj_center(lp);
|
||||
|
||||
btn_next = lv_button_create(bot);
|
||||
lv_obj_set_pos(btn_next, UI_HOR_RES - 64, 6);
|
||||
lv_obj_set_size(btn_next, 56, 28);
|
||||
lv_obj_add_flag(btn_next, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_add_event_cb(btn_next, on_next, LV_EVENT_CLICKED, nullptr);
|
||||
style_btn(btn_next, COL_BTN_HI);
|
||||
lv_obj_t *ln = lv_label_create(btn_next);
|
||||
lv_label_set_text(ln, ">");
|
||||
lv_obj_set_style_text_font(ln, &lv_font_montserrat_16, 0);
|
||||
lv_obj_set_style_text_color(ln, lv_color_hex(COL_INK), 0);
|
||||
lv_obj_center(ln);
|
||||
|
||||
lbl_name = lv_label_create(bot);
|
||||
lv_label_set_text(lbl_name, "-");
|
||||
lv_label_set_long_mode(lbl_name, LV_LABEL_LONG_CLIP);
|
||||
lv_obj_set_width(lbl_name, UI_HOR_RES - 140);
|
||||
lv_obj_set_style_text_font(lbl_name, &lv_font_montserrat_14, 0);
|
||||
lv_obj_set_style_text_color(lbl_name, lv_color_hex(COL_INK), 0);
|
||||
lv_obj_align(lbl_name, LV_ALIGN_TOP_MID, 0, 4);
|
||||
|
||||
lbl_hint = lv_label_create(bot);
|
||||
lv_label_set_text(lbl_hint, "Tap left/right");
|
||||
lv_obj_set_style_text_font(lbl_hint, &lv_font_montserrat_14, 0);
|
||||
lv_obj_set_style_text_color(lbl_hint, lv_color_hex(COL_MUTED), 0);
|
||||
lv_obj_align(lbl_hint, LV_ALIGN_TOP_MID, 0, 20);
|
||||
|
||||
// Raise bars / buttons above tap target for hit testing
|
||||
lv_obj_move_foreground(top);
|
||||
lv_obj_move_foreground(bot);
|
||||
}
|
||||
|
||||
void ui_tick() {
|
||||
if (photo_view_gif_tick() && img_photo) {
|
||||
lv_obj_invalidate(img_photo);
|
||||
}
|
||||
}
|
||||
|
||||
void ui_update(const SdCardInfo &info) {
|
||||
if (!lbl_chip) {
|
||||
return;
|
||||
}
|
||||
if (!info.mounted || !info.present) {
|
||||
lv_label_set_text(lbl_chip, info.error.length() ? info.error.c_str() : "TF?");
|
||||
lv_obj_set_style_text_color(lbl_chip, lv_color_hex(COL_BAD), 0);
|
||||
} else {
|
||||
char chip[40];
|
||||
snprintf(chip, sizeof(chip), "OK %s", info.type);
|
||||
lv_label_set_text(lbl_chip, chip);
|
||||
lv_obj_set_style_text_color(lbl_chip, lv_color_hex(COL_OK), 0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user