Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d9001fcd7 | |||
| 653ad8902f | |||
| e4d1d35da4 | |||
| 6eb032cb53 | |||
| 07749d86a1 |
+179
-154
@@ -4,7 +4,7 @@
|
||||
#include <tt_app_alertdialog.h>
|
||||
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/drivers/audio_stream.h>
|
||||
#include <tactility/drivers/i2s_controller.h>
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
@@ -45,15 +45,14 @@ typedef struct {
|
||||
} BookMetadata;
|
||||
|
||||
typedef struct {
|
||||
struct Device* stream_dev;
|
||||
AudioStreamHandle stream_handle;
|
||||
struct Device* i2s_dev;
|
||||
PlaybackState state;
|
||||
int volume;
|
||||
|
||||
|
||||
// Book picker list data
|
||||
BookMetadata books[MAX_BOOKS];
|
||||
int book_count;
|
||||
|
||||
|
||||
// Currently loaded book details
|
||||
char current_book_slug[MAX_PATH];
|
||||
cJSON* manifest_root;
|
||||
@@ -61,12 +60,12 @@ typedef struct {
|
||||
int page_count;
|
||||
int current_page;
|
||||
int last_pct;
|
||||
|
||||
|
||||
// UI elements
|
||||
AppHandle app;
|
||||
lv_obj_t* picker_wrapper;
|
||||
lv_obj_t* lst_books;
|
||||
|
||||
|
||||
lv_obj_t* player_wrapper;
|
||||
lv_obj_t* header_bar;
|
||||
lv_obj_t* ctrl_bar;
|
||||
@@ -77,29 +76,29 @@ typedef struct {
|
||||
lv_obj_t* btn_play_pause;
|
||||
lv_obj_t* btn_prev;
|
||||
lv_obj_t* btn_next;
|
||||
|
||||
|
||||
// Audio State
|
||||
char current_audio_path[512];
|
||||
uint8_t* audio_buf; // Shared MP3 input and WAV buffer
|
||||
mp3d_sample_t* pcm_buf; // MP3 decoded pcm buffer
|
||||
|
||||
|
||||
TaskHandle_t playback_task_handle;
|
||||
} AppCtx;
|
||||
|
||||
typedef struct __attribute__((packed)) {
|
||||
char riff[4];
|
||||
uint32_t overall_size;
|
||||
char wave[4];
|
||||
char fmt_chunk_marker[4];
|
||||
uint32_t length_of_fmt;
|
||||
uint16_t format_type;
|
||||
uint16_t channels;
|
||||
uint32_t sample_rate;
|
||||
uint32_t byterate;
|
||||
uint16_t block_align;
|
||||
uint16_t bits_per_sample;
|
||||
char data_chunk_header[4];
|
||||
uint32_t data_size;
|
||||
char riff[4]; // "RIFF"
|
||||
uint32_t overall_size; // file size - 8
|
||||
char wave[4]; // "WAVE"
|
||||
char fmt_chunk_marker[4]; // "fmt "
|
||||
uint32_t length_of_fmt; // 16 for PCM
|
||||
uint16_t format_type; // 1 for PCM
|
||||
uint16_t channels; // 1 for mono
|
||||
uint32_t sample_rate; // 16000
|
||||
uint32_t byterate; // sample_rate * channels * (bits_per_sample / 8)
|
||||
uint16_t block_align; // channels * (bits_per_sample / 8)
|
||||
uint16_t bits_per_sample; // 16
|
||||
char data_chunk_header[4]; // "data"
|
||||
uint32_t data_size; // data size in bytes
|
||||
} WavHeader;
|
||||
|
||||
static AppCtx g_ctx;
|
||||
@@ -114,60 +113,19 @@ static void play_mp3(AppCtx* ctx);
|
||||
static void play_wav(AppCtx* ctx);
|
||||
static void scan_books(AppCtx* ctx);
|
||||
|
||||
/* ─── Audio-stream helpers ─── */
|
||||
static bool find_audio_stream_device(AppCtx* ctx) {
|
||||
// Prefer device_find_first_by_type but keep compatibility with name lookup
|
||||
struct Device* dev = device_find_by_name("audio-stream");
|
||||
if (dev) {
|
||||
ctx->stream_dev = dev;
|
||||
return true;
|
||||
}
|
||||
dev = device_find_first_by_type(&AUDIO_STREAM_TYPE);
|
||||
if (dev) {
|
||||
ctx->stream_dev = dev;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static void close_stream_if_open(AppCtx* ctx) {
|
||||
if (ctx->stream_handle) {
|
||||
audio_stream_close(ctx->stream_handle);
|
||||
ctx->stream_handle = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
static bool open_output_stream(AppCtx* ctx, uint32_t sample_rate, uint8_t channels, uint8_t bits) {
|
||||
close_stream_if_open(ctx);
|
||||
if (!ctx->stream_dev) return false;
|
||||
struct AudioStreamConfig cfg = {
|
||||
.sample_rate = sample_rate,
|
||||
.bits_per_sample = bits,
|
||||
.channels = channels
|
||||
};
|
||||
error_t err = audio_stream_open_output(ctx->stream_dev, &cfg, &ctx->stream_handle);
|
||||
if (err != ERROR_NONE) {
|
||||
ESP_LOGE(TAG, "audio_stream_open_output failed: %d (rate=%u ch=%u)", err, (unsigned)sample_rate, channels);
|
||||
ctx->stream_handle = NULL;
|
||||
return false;
|
||||
}
|
||||
ESP_LOGI(TAG, "audio_stream output opened: %u Hz %u ch %u-bit", (unsigned)sample_rate, channels, bits);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ─── UI Helper to update status labels & button states ─── */
|
||||
static void update_ui(AppCtx* ctx) {
|
||||
if (!ctx->btn_play_pause) return;
|
||||
|
||||
lv_obj_t* lbl_play = lv_obj_get_child(ctx->btn_play_pause, 0);
|
||||
|
||||
|
||||
// Prev button state
|
||||
if (ctx->current_page <= 0) {
|
||||
lv_obj_add_state(ctx->btn_prev, LV_STATE_DISABLED);
|
||||
} else {
|
||||
lv_obj_clear_state(ctx->btn_prev, LV_STATE_DISABLED);
|
||||
}
|
||||
|
||||
|
||||
// Next button state
|
||||
if (ctx->current_page >= ctx->page_count - 1) {
|
||||
lv_obj_add_state(ctx->btn_next, LV_STATE_DISABLED);
|
||||
@@ -204,13 +162,13 @@ static char* read_full_file(const char* filepath) {
|
||||
return NULL;
|
||||
}
|
||||
fseek(file, 0, SEEK_SET);
|
||||
|
||||
|
||||
char* buf = malloc(size + 1);
|
||||
if (!buf) {
|
||||
fclose(file);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
|
||||
size_t read_bytes = fread(buf, 1, size, file);
|
||||
buf[read_bytes] = '\0';
|
||||
fclose(file);
|
||||
@@ -227,6 +185,7 @@ static void on_auto_advance_timer(lv_timer_t* timer) {
|
||||
|
||||
static void handle_audio_finished(AppCtx* ctx) {
|
||||
if (ctx->current_page + 1 < ctx->page_count) {
|
||||
// Create a one-shot timer with repeat count 1 to run on the GUI thread
|
||||
lv_timer_t* timer = lv_timer_create(on_auto_advance_timer, 0, ctx);
|
||||
lv_timer_set_repeat_count(timer, 1);
|
||||
} else {
|
||||
@@ -249,6 +208,7 @@ static void wait_for_playback_task_to_exit(AppCtx* ctx) {
|
||||
|
||||
/* ─── Load Page Content (Image, Caption, Audio path) ─── */
|
||||
static void load_page(AppCtx* ctx, int page_index, bool start_audio) {
|
||||
// 1. Stop current audio if running and wait for thread to finish
|
||||
wait_for_playback_task_to_exit(ctx);
|
||||
|
||||
if (page_index < 0 || page_index >= ctx->page_count) return;
|
||||
@@ -265,7 +225,7 @@ static void load_page(AppCtx* ctx, int page_index, bool start_audio) {
|
||||
if (img_item && img_item->valuestring) {
|
||||
char img_path[512];
|
||||
snprintf(img_path, sizeof(img_path), "/sdcard/books/%s/%s", ctx->current_book_slug, img_item->valuestring);
|
||||
|
||||
|
||||
FILE* img_file = fopen(img_path, "rb");
|
||||
if (img_file) {
|
||||
fclose(img_file);
|
||||
@@ -278,13 +238,15 @@ static void load_page(AppCtx* ctx, int page_index, bool start_audio) {
|
||||
lv_image_set_src(ctx->img_page, lv_img_path);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Image file not found: %s", img_path);
|
||||
lv_image_set_src(ctx->img_page, LV_SYMBOL_IMAGE);
|
||||
lv_image_set_src(ctx->img_page, LV_SYMBOL_IMAGE); // Show default fallback icon
|
||||
}
|
||||
} else {
|
||||
lv_image_set_src(ctx->img_page, LV_SYMBOL_IMAGE);
|
||||
}
|
||||
|
||||
// Update Page index indicator
|
||||
|
||||
|
||||
// Update Page index indicator (e.g. "1 / 12")
|
||||
char ind_buf[32];
|
||||
snprintf(ind_buf, sizeof(ind_buf), "%d / %d", page_index + 1, ctx->page_count);
|
||||
lv_label_set_text(ctx->lbl_indicator, ind_buf);
|
||||
@@ -296,16 +258,18 @@ static void load_page(AppCtx* ctx, int page_index, bool start_audio) {
|
||||
ctx->current_audio_path[0] = '\0';
|
||||
}
|
||||
|
||||
// Update UI states (like Prev/Next buttons)
|
||||
update_ui(ctx);
|
||||
|
||||
// Start playback if requested and valid
|
||||
if (start_audio && ctx->current_audio_path[0] != '\0') {
|
||||
// Verify audio file exists
|
||||
FILE* audio_file = fopen(ctx->current_audio_path, "rb");
|
||||
if (audio_file) {
|
||||
fclose(audio_file);
|
||||
ctx->state = STATE_PLAYING;
|
||||
update_ui(ctx);
|
||||
xTaskCreate(audio_playback_task, "audio_play", 8192, ctx, 5, &ctx->playback_task_handle);
|
||||
xTaskCreate(audio_playback_task, "audio_play", 4096, ctx, 5, &ctx->playback_task_handle);
|
||||
} else {
|
||||
ESP_LOGE(TAG, "Audio file not found: %s", ctx->current_audio_path);
|
||||
const char* buttons[] = {"OK"};
|
||||
@@ -336,7 +300,7 @@ static void return_to_picker(AppCtx* ctx) {
|
||||
/* ─── Task Selection (MP3 or WAV) ─── */
|
||||
static void audio_playback_task(void* arg) {
|
||||
AppCtx* ctx = (AppCtx*)arg;
|
||||
|
||||
|
||||
size_t len = strlen(ctx->current_audio_path);
|
||||
bool is_wav = false;
|
||||
if (len > 4 && strcasecmp(ctx->current_audio_path + len - 4, ".wav") == 0) {
|
||||
@@ -350,8 +314,9 @@ static void audio_playback_task(void* arg) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── MP3 Decoder Routine (audio-stream) ─── */
|
||||
/* ─── MP3 Decoder Routine ─── */
|
||||
static void play_mp3(AppCtx* ctx) {
|
||||
// Let the GUI thread load the image from SD first to avoid hardware SD card bus contention
|
||||
vTaskDelay(pdMS_TO_TICKS(400));
|
||||
|
||||
FILE* file = fopen(ctx->current_audio_path, "rb");
|
||||
@@ -389,12 +354,14 @@ static void play_mp3(AppCtx* ctx) {
|
||||
int sample_rate = 0;
|
||||
int channels = 0;
|
||||
|
||||
ESP_LOGI(TAG, "Starting MP3 playback via audio-stream: %s (%d bytes)", ctx->current_audio_path, file_size);
|
||||
ESP_LOGI(TAG, "Starting MP3 playback: %s (%d bytes)", ctx->current_audio_path, file_size);
|
||||
|
||||
while (ctx->state != STATE_IDLE) {
|
||||
if (ctx->state == STATE_PAUSED) {
|
||||
if (ctx->stream_handle) {
|
||||
close_stream_if_open(ctx);
|
||||
if (sample_rate != 0) {
|
||||
device_lock(ctx->i2s_dev);
|
||||
i2s_controller_reset(ctx->i2s_dev);
|
||||
device_unlock(ctx->i2s_dev);
|
||||
sample_rate = 0;
|
||||
channels = 0;
|
||||
}
|
||||
@@ -423,6 +390,7 @@ static void play_mp3(AppCtx* ctx) {
|
||||
|
||||
if (info.frame_bytes <= 0) {
|
||||
if (eof) break;
|
||||
// Resync
|
||||
memmove(ctx->audio_buf, ctx->audio_buf + 1, --buffered_bytes);
|
||||
continue;
|
||||
}
|
||||
@@ -432,10 +400,22 @@ static void play_mp3(AppCtx* ctx) {
|
||||
memmove(ctx->audio_buf, ctx->audio_buf + consumed, buffered_bytes);
|
||||
|
||||
if (samples > 0) {
|
||||
// Open/reopen stream on format change
|
||||
// Configure I2S
|
||||
if (sample_rate != info.hz || channels != info.channels) {
|
||||
if (!open_output_stream(ctx, (uint32_t)info.hz, (uint8_t)info.channels, 16)) {
|
||||
ESP_LOGE(TAG, "Failed to open audio stream for MP3: %d Hz %d ch", info.hz, info.channels);
|
||||
struct I2sConfig config = {
|
||||
.communication_format = I2S_FORMAT_STAND_I2S,
|
||||
.sample_rate = (uint32_t)info.hz,
|
||||
.bits_per_sample = 16,
|
||||
.channel_left = 0,
|
||||
.channel_right = (info.channels == 2) ? 1 : I2S_CHANNEL_NONE
|
||||
};
|
||||
|
||||
device_lock(ctx->i2s_dev);
|
||||
error_t err = i2s_controller_set_config(ctx->i2s_dev, &config);
|
||||
device_unlock(ctx->i2s_dev);
|
||||
|
||||
if (err != ERROR_NONE) {
|
||||
ESP_LOGE(TAG, "Failed to configure I2S: %d", err);
|
||||
break;
|
||||
}
|
||||
sample_rate = info.hz;
|
||||
@@ -451,15 +431,15 @@ static void play_mp3(AppCtx* ctx) {
|
||||
samples_ptr[i] = (int16_t)scaled;
|
||||
}
|
||||
|
||||
// Write via audio_stream (resampled to native 44100 internally)
|
||||
// Write PCM to I2S
|
||||
size_t offset = 0;
|
||||
size_t data_size = sample_count * sizeof(int16_t);
|
||||
bool write_err = false;
|
||||
while (offset < data_size && ctx->state == STATE_PLAYING) {
|
||||
size_t written = 0;
|
||||
error_t err = audio_stream_write(ctx->stream_handle, (uint8_t*)ctx->pcm_buf + offset, data_size - offset, &written, pdMS_TO_TICKS(1000));
|
||||
error_t err = i2s_controller_write(ctx->i2s_dev, (uint8_t*)ctx->pcm_buf + offset, data_size - offset, &written, pdMS_TO_TICKS(250));
|
||||
if (err != ERROR_NONE || written == 0) {
|
||||
ESP_LOGE(TAG, "audio_stream_write error: %d", err);
|
||||
ESP_LOGE(TAG, "I2S write error: %d", err);
|
||||
write_err = true;
|
||||
break;
|
||||
}
|
||||
@@ -485,7 +465,12 @@ static void play_mp3(AppCtx* ctx) {
|
||||
}
|
||||
|
||||
fclose(file);
|
||||
close_stream_if_open(ctx);
|
||||
|
||||
if (ctx->i2s_dev) {
|
||||
device_lock(ctx->i2s_dev);
|
||||
i2s_controller_reset(ctx->i2s_dev);
|
||||
device_unlock(ctx->i2s_dev);
|
||||
}
|
||||
|
||||
bool stopped_externally = (ctx->state == STATE_IDLE);
|
||||
|
||||
@@ -504,8 +489,9 @@ static void play_mp3(AppCtx* ctx) {
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
/* ─── WAV Decoder Routine (audio-stream) ─── */
|
||||
/* ─── WAV Decoder Routine ─── */
|
||||
static void play_wav(AppCtx* ctx) {
|
||||
// Let the GUI thread load the image from SD first to avoid hardware SD card bus contention
|
||||
vTaskDelay(pdMS_TO_TICKS(400));
|
||||
|
||||
FILE* file = fopen(ctx->current_audio_path, "rb");
|
||||
@@ -552,36 +538,49 @@ static void play_wav(AppCtx* ctx) {
|
||||
fseek(file, sizeof(WavHeader), SEEK_SET);
|
||||
}
|
||||
|
||||
if (!open_output_stream(ctx, header.sample_rate, header.channels, header.bits_per_sample)) {
|
||||
ESP_LOGE(TAG, "Failed to open audio stream for WAV: %u Hz %u ch", (unsigned)header.sample_rate, header.channels);
|
||||
struct I2sConfig config = {
|
||||
.communication_format = I2S_FORMAT_STAND_I2S,
|
||||
.sample_rate = header.sample_rate,
|
||||
.bits_per_sample = header.bits_per_sample,
|
||||
.channel_left = 0,
|
||||
.channel_right = (header.channels == 2) ? 1 : I2S_CHANNEL_NONE
|
||||
};
|
||||
|
||||
device_lock(ctx->i2s_dev);
|
||||
error_t err = i2s_controller_set_config(ctx->i2s_dev, &config);
|
||||
device_unlock(ctx->i2s_dev);
|
||||
|
||||
if (err != ERROR_NONE) {
|
||||
ESP_LOGE(TAG, "Failed to configure WAV I2S: %d", err);
|
||||
fclose(file);
|
||||
tt_lvgl_lock(portMAX_DELAY);
|
||||
ctx->state = STATE_IDLE;
|
||||
update_ui(ctx);
|
||||
tt_lvgl_unlock();
|
||||
ctx->playback_task_handle = NULL;
|
||||
vTaskDelete(NULL);
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Starting WAV via audio-stream: %s (%u Hz, %u ch)", ctx->current_audio_path, (unsigned int)header.sample_rate, (unsigned int)header.channels);
|
||||
ESP_LOGI(TAG, "Starting WAV playback: %s (%u Hz, %u channels)", ctx->current_audio_path, (unsigned int)header.sample_rate, (unsigned int)header.channels);
|
||||
|
||||
size_t total_played = 0;
|
||||
bool stream_open = true;
|
||||
bool i2s_configured = true;
|
||||
|
||||
while (total_played < data_size && ctx->state != STATE_IDLE) {
|
||||
if (ctx->state == STATE_PAUSED) {
|
||||
if (stream_open) {
|
||||
close_stream_if_open(ctx);
|
||||
stream_open = false;
|
||||
if (i2s_configured) {
|
||||
device_lock(ctx->i2s_dev);
|
||||
i2s_controller_reset(ctx->i2s_dev);
|
||||
device_unlock(ctx->i2s_dev);
|
||||
i2s_configured = false;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(50));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!stream_open) {
|
||||
if (!open_output_stream(ctx, header.sample_rate, header.channels, header.bits_per_sample)) break;
|
||||
stream_open = true;
|
||||
if (!i2s_configured) {
|
||||
device_lock(ctx->i2s_dev);
|
||||
err = i2s_controller_set_config(ctx->i2s_dev, &config);
|
||||
device_unlock(ctx->i2s_dev);
|
||||
if (err != ERROR_NONE) break;
|
||||
i2s_configured = true;
|
||||
}
|
||||
|
||||
size_t to_read = (data_size - total_played < MP3_INPUT_BUFFER_SIZE) ? (data_size - total_played) : MP3_INPUT_BUFFER_SIZE;
|
||||
@@ -597,14 +596,14 @@ static void play_wav(AppCtx* ctx) {
|
||||
samples_ptr[i] = (int16_t)scaled;
|
||||
}
|
||||
|
||||
// Write via audio_stream
|
||||
// Play to I2S
|
||||
size_t offset = 0;
|
||||
bool write_err = false;
|
||||
while (offset < read_bytes && ctx->state == STATE_PLAYING) {
|
||||
size_t written = 0;
|
||||
error_t err = audio_stream_write(ctx->stream_handle, ctx->audio_buf + offset, read_bytes - offset, &written, pdMS_TO_TICKS(500));
|
||||
err = i2s_controller_write(ctx->i2s_dev, ctx->audio_buf + offset, read_bytes - offset, &written, pdMS_TO_TICKS(100));
|
||||
if (err != ERROR_NONE || written == 0) {
|
||||
ESP_LOGE(TAG, "audio_stream WAV write error: %d", err);
|
||||
ESP_LOGE(TAG, "I2S WAV write error: %d", err);
|
||||
write_err = true;
|
||||
break;
|
||||
}
|
||||
@@ -615,6 +614,7 @@ static void play_wav(AppCtx* ctx) {
|
||||
|
||||
total_played += read_bytes;
|
||||
|
||||
// Update progress bar
|
||||
int pct = (int)((total_played * 100) / data_size);
|
||||
if (pct < 0) pct = 0;
|
||||
if (pct > 100) pct = 100;
|
||||
@@ -629,7 +629,12 @@ static void play_wav(AppCtx* ctx) {
|
||||
}
|
||||
|
||||
fclose(file);
|
||||
close_stream_if_open(ctx);
|
||||
|
||||
if (ctx->i2s_dev) {
|
||||
device_lock(ctx->i2s_dev);
|
||||
i2s_controller_reset(ctx->i2s_dev);
|
||||
device_unlock(ctx->i2s_dev);
|
||||
}
|
||||
|
||||
bool stopped_externally = (ctx->state == STATE_IDLE);
|
||||
|
||||
@@ -647,29 +652,29 @@ static void play_wav(AppCtx* ctx) {
|
||||
static void on_book_selected(lv_event_t* e) {
|
||||
int index = (int)(intptr_t)lv_event_get_user_data(e);
|
||||
AppCtx* ctx = &g_ctx;
|
||||
|
||||
|
||||
BookMetadata* book = &ctx->books[index];
|
||||
strncpy(ctx->current_book_slug, book->slug, sizeof(ctx->current_book_slug) - 1);
|
||||
|
||||
|
||||
char manifest_path[512];
|
||||
snprintf(manifest_path, sizeof(manifest_path), "/sdcard/books/%s/manifest.json", book->slug);
|
||||
|
||||
|
||||
char* json_str = read_full_file(manifest_path);
|
||||
if (!json_str) {
|
||||
const char* buttons[] = {"OK"};
|
||||
tt_app_alertdialog_start("Read Error", "Failed to open the book manifest.", buttons, 1);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
ctx->manifest_root = cJSON_Parse(json_str);
|
||||
free(json_str);
|
||||
|
||||
|
||||
if (!ctx->manifest_root) {
|
||||
const char* buttons[] = {"OK"};
|
||||
tt_app_alertdialog_start("JSON Error", "The book manifest is not formatted correctly.", buttons, 1);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
ctx->pages_array = cJSON_GetObjectItem(ctx->manifest_root, "pages");
|
||||
if (!ctx->pages_array || !cJSON_IsArray(ctx->pages_array)) {
|
||||
const char* buttons[] = {"OK"};
|
||||
@@ -679,7 +684,7 @@ static void on_book_selected(lv_event_t* e) {
|
||||
ctx->pages_array = NULL;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
ctx->page_count = cJSON_GetArraySize(ctx->pages_array);
|
||||
if (ctx->page_count <= 0) {
|
||||
const char* buttons[] = {"OK"};
|
||||
@@ -689,16 +694,17 @@ static void on_book_selected(lv_event_t* e) {
|
||||
ctx->pages_array = NULL;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
lv_label_set_text(ctx->lbl_book_title, book->title);
|
||||
|
||||
|
||||
// Switch views
|
||||
lv_obj_add_flag(ctx->picker_wrapper, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_remove_flag(ctx->player_wrapper, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_remove_flag(ctx->header_bar, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_remove_flag(ctx->ctrl_bar, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_remove_flag(ctx->bar_progress, LV_OBJ_FLAG_HIDDEN);
|
||||
|
||||
|
||||
// Load first page (no auto-play initially)
|
||||
load_page(ctx, 0, false);
|
||||
}
|
||||
|
||||
@@ -706,57 +712,57 @@ static void on_book_selected(lv_event_t* e) {
|
||||
static void scan_books(AppCtx* ctx) {
|
||||
ctx->book_count = 0;
|
||||
lv_obj_clean(ctx->lst_books);
|
||||
|
||||
|
||||
DIR* dir = opendir("/sdcard/books");
|
||||
if (!dir) {
|
||||
ESP_LOGE(TAG, "Failed to scan books: /sdcard/books folder missing.");
|
||||
lv_list_add_text(ctx->lst_books, "No SD card or books directory found.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
struct dirent* entry;
|
||||
while ((entry = readdir(dir)) != NULL && ctx->book_count < MAX_BOOKS) {
|
||||
if (entry->d_name[0] == '.') continue;
|
||||
|
||||
|
||||
char manifest_path[512];
|
||||
snprintf(manifest_path, sizeof(manifest_path), "/sdcard/books/%s/manifest.json", entry->d_name);
|
||||
|
||||
|
||||
char* json_str = read_full_file(manifest_path);
|
||||
if (json_str) {
|
||||
cJSON* root = cJSON_Parse(json_str);
|
||||
free(json_str);
|
||||
|
||||
|
||||
if (root) {
|
||||
cJSON* title_item = cJSON_GetObjectItem(root, "title");
|
||||
cJSON* author_item = cJSON_GetObjectItem(root, "author");
|
||||
|
||||
|
||||
BookMetadata* book = &ctx->books[ctx->book_count];
|
||||
strncpy(book->slug, entry->d_name, sizeof(book->slug) - 1);
|
||||
|
||||
|
||||
if (title_item && title_item->valuestring) {
|
||||
strncpy(book->title, title_item->valuestring, sizeof(book->title) - 1);
|
||||
} else {
|
||||
strncpy(book->title, entry->d_name, sizeof(book->title) - 1);
|
||||
}
|
||||
|
||||
|
||||
if (author_item && author_item->valuestring) {
|
||||
strncpy(book->author, author_item->valuestring, sizeof(book->author) - 1);
|
||||
} else {
|
||||
book->author[0] = '\0';
|
||||
}
|
||||
|
||||
|
||||
cJSON_Delete(root);
|
||||
ctx->book_count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
closedir(dir);
|
||||
|
||||
|
||||
if (ctx->book_count == 0) {
|
||||
lv_list_add_text(ctx->lst_books, "No book manifest.json files found.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Bubble sort by title
|
||||
for (int i = 0; i < ctx->book_count - 1; i++) {
|
||||
for (int j = 0; j < ctx->book_count - i - 1; j++) {
|
||||
@@ -767,7 +773,7 @@ static void scan_books(AppCtx* ctx) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Add items to screen list
|
||||
for (int i = 0; i < ctx->book_count; ++i) {
|
||||
char label_text[512];
|
||||
@@ -789,7 +795,7 @@ static void on_play_pause_click(lv_event_t* e) {
|
||||
if (ctx->state == STATE_IDLE) {
|
||||
ctx->state = STATE_PLAYING;
|
||||
update_ui(ctx);
|
||||
xTaskCreate(audio_playback_task, "audio_play", 8192, ctx, 5, &ctx->playback_task_handle);
|
||||
xTaskCreate(audio_playback_task, "audio_play", 4096, ctx, 5, &ctx->playback_task_handle);
|
||||
} else if (ctx->state == STATE_PLAYING) {
|
||||
ctx->state = STATE_PAUSED;
|
||||
update_ui(ctx);
|
||||
@@ -818,7 +824,7 @@ static void on_next_click(lv_event_t* e) {
|
||||
static void on_image_click(lv_event_t* e) {
|
||||
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
|
||||
if (!ctx) return;
|
||||
|
||||
|
||||
bool is_hidden = lv_obj_has_flag(ctx->header_bar, LV_OBJ_FLAG_HIDDEN);
|
||||
if (is_hidden) {
|
||||
lv_obj_remove_flag(ctx->header_bar, LV_OBJ_FLAG_HIDDEN);
|
||||
@@ -841,7 +847,7 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
||||
memset(&g_ctx, 0, sizeof(g_ctx));
|
||||
g_ctx.app = app;
|
||||
g_ctx.volume = 80;
|
||||
|
||||
|
||||
// Allocate shared audio buffers
|
||||
#ifdef ESP_PLATFORM
|
||||
g_ctx.audio_buf = (uint8_t*)heap_caps_malloc(MP3_INPUT_BUFFER_SIZE, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||
@@ -850,12 +856,13 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
||||
g_ctx.audio_buf = (uint8_t*)malloc(MP3_INPUT_BUFFER_SIZE);
|
||||
g_ctx.pcm_buf = (mp3d_sample_t*)malloc(MINIMP3_MAX_SAMPLES_PER_FRAME * sizeof(mp3d_sample_t));
|
||||
#endif
|
||||
|
||||
// Find audio-stream device (provides resampling to native 44100)
|
||||
if (!find_audio_stream_device(&g_ctx)) {
|
||||
ESP_LOGE(TAG, "audio-stream device not found! Tried 'audio-stream' name and AUDIO_STREAM_TYPE");
|
||||
|
||||
// Find I2S sound device
|
||||
g_ctx.i2s_dev = device_find_by_name("i2s0");
|
||||
if (!g_ctx.i2s_dev) {
|
||||
ESP_LOGE(TAG, "I2S device 'i2s0' not found!");
|
||||
}
|
||||
|
||||
|
||||
// Create dual layouts
|
||||
// 1. Picker Screen wrapper
|
||||
g_ctx.picker_wrapper = lv_obj_create(parent);
|
||||
@@ -863,11 +870,13 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
||||
lv_obj_set_style_border_width(g_ctx.picker_wrapper, 0, 0);
|
||||
lv_obj_set_style_pad_all(g_ctx.picker_wrapper, 0, 0);
|
||||
lv_obj_set_style_pad_gap(g_ctx.picker_wrapper, 0, 0);
|
||||
lv_obj_set_style_bg_color(g_ctx.picker_wrapper, lv_color_hex(0x1E1E2E), 0);
|
||||
|
||||
lv_obj_set_style_bg_color(g_ctx.picker_wrapper, lv_color_hex(0x1E1E2E), 0); // Dark background
|
||||
|
||||
// Toolbar in picker
|
||||
lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(g_ctx.picker_wrapper, app);
|
||||
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
|
||||
|
||||
|
||||
// Picker list
|
||||
g_ctx.lst_books = lv_list_create(g_ctx.picker_wrapper);
|
||||
lv_obj_set_width(g_ctx.lst_books, LV_PCT(100));
|
||||
lv_obj_align_to(g_ctx.lst_books, toolbar, LV_ALIGN_OUT_BOTTOM_MID, 0, 0);
|
||||
@@ -876,7 +885,7 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
||||
lv_obj_set_height(g_ctx.lst_books, parent_height - toolbar_height);
|
||||
lv_obj_set_style_bg_color(g_ctx.lst_books, lv_color_hex(0x1E1E2E), 0);
|
||||
lv_obj_set_style_border_color(g_ctx.lst_books, lv_color_hex(0x313244), 0);
|
||||
|
||||
|
||||
// 2. Player Screen wrapper (hidden on start)
|
||||
g_ctx.player_wrapper = lv_obj_create(parent);
|
||||
lv_obj_set_size(g_ctx.player_wrapper, LV_PCT(100), LV_PCT(100));
|
||||
@@ -884,9 +893,10 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
||||
lv_obj_set_style_pad_all(g_ctx.player_wrapper, 0, 0);
|
||||
lv_obj_set_style_pad_gap(g_ctx.player_wrapper, 0, 0);
|
||||
lv_obj_set_style_bg_color(g_ctx.player_wrapper, lv_color_hex(0x1E1E2E), 0);
|
||||
lv_obj_remove_flag(g_ctx.player_wrapper, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_remove_flag(g_ctx.player_wrapper, LV_OBJ_FLAG_SCROLLABLE); // Lock page scrolling
|
||||
lv_obj_add_flag(g_ctx.player_wrapper, LV_OBJ_FLAG_HIDDEN);
|
||||
|
||||
|
||||
// Page Image (covers the whole screen as background)
|
||||
g_ctx.img_page = lv_image_create(g_ctx.player_wrapper);
|
||||
lv_obj_align(g_ctx.img_page, LV_ALIGN_CENTER, 0, 0);
|
||||
lv_obj_set_style_bg_opa(g_ctx.img_page, LV_OPA_TRANSP, 0);
|
||||
@@ -895,18 +905,20 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
||||
lv_obj_remove_flag(g_ctx.img_page, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_add_flag(g_ctx.img_page, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_add_event_cb(g_ctx.img_page, on_image_click, LV_EVENT_CLICKED, &g_ctx);
|
||||
|
||||
|
||||
// Custom Player header (Back button, Book Title, Page Indicator)
|
||||
g_ctx.header_bar = lv_obj_create(g_ctx.player_wrapper);
|
||||
lv_obj_t* header_bar = g_ctx.header_bar;
|
||||
lv_obj_set_size(header_bar, LV_PCT(100), 36);
|
||||
lv_obj_align(header_bar, LV_ALIGN_TOP_MID, 0, 0);
|
||||
lv_obj_set_style_radius(header_bar, 0, 0);
|
||||
lv_obj_set_style_bg_color(header_bar, lv_color_hex(0x11111B), 0);
|
||||
lv_obj_set_style_bg_opa(header_bar, LV_OPA_COVER, 0);
|
||||
lv_obj_set_style_bg_opa(header_bar, LV_OPA_COVER, 0); // Solid header bar
|
||||
lv_obj_set_style_border_color(header_bar, lv_color_hex(0x313244), 0);
|
||||
lv_obj_set_style_border_width(header_bar, 1, LV_PART_MAIN);
|
||||
lv_obj_remove_flag(header_bar, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
|
||||
// Back button
|
||||
lv_obj_t* btn_back = lv_button_create(header_bar);
|
||||
lv_obj_set_size(btn_back, 44, 26);
|
||||
lv_obj_align(btn_back, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
@@ -916,33 +928,37 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
||||
lv_label_set_text(lbl_back, LV_SYMBOL_LEFT);
|
||||
lv_obj_center(lbl_back);
|
||||
lv_obj_add_event_cb(btn_back, on_back_click, LV_EVENT_CLICKED, &g_ctx);
|
||||
|
||||
|
||||
// Title
|
||||
g_ctx.lbl_book_title = lv_label_create(header_bar);
|
||||
lv_obj_align(g_ctx.lbl_book_title, LV_ALIGN_CENTER, 0, 0);
|
||||
lv_obj_set_width(g_ctx.lbl_book_title, 180);
|
||||
lv_obj_set_style_text_align(g_ctx.lbl_book_title, LV_TEXT_ALIGN_CENTER, 0);
|
||||
lv_obj_set_style_text_color(g_ctx.lbl_book_title, lv_color_hex(0xCDD6F4), 0);
|
||||
lv_label_set_long_mode(g_ctx.lbl_book_title, LV_LABEL_LONG_DOT);
|
||||
|
||||
lv_label_set_long_mode(g_ctx.lbl_book_title, LV_LABEL_LONG_DOT); // Static text with dots to prevent dynamic redraw overhead
|
||||
|
||||
// Page Indicator
|
||||
g_ctx.lbl_indicator = lv_label_create(header_bar);
|
||||
lv_obj_align(g_ctx.lbl_indicator, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
lv_obj_set_style_text_color(g_ctx.lbl_indicator, lv_color_hex(0xA6ADC8), 0);
|
||||
lv_label_set_text(g_ctx.lbl_indicator, "1 / 1");
|
||||
|
||||
|
||||
// Bottom Controls container (overlaying background image)
|
||||
g_ctx.ctrl_bar = lv_obj_create(g_ctx.player_wrapper);
|
||||
lv_obj_t* ctrl_bar = g_ctx.ctrl_bar;
|
||||
lv_obj_set_size(ctrl_bar, LV_PCT(100), 40);
|
||||
lv_obj_align(ctrl_bar, LV_ALIGN_BOTTOM_MID, 0, 0);
|
||||
lv_obj_set_style_radius(ctrl_bar, 0, 0);
|
||||
lv_obj_set_style_bg_color(ctrl_bar, lv_color_hex(0x11111B), 0);
|
||||
lv_obj_set_style_bg_opa(ctrl_bar, LV_OPA_COVER, 0);
|
||||
lv_obj_set_style_bg_opa(ctrl_bar, LV_OPA_COVER, 0); // Solid control bar
|
||||
lv_obj_set_style_border_width(ctrl_bar, 0, 0);
|
||||
lv_obj_set_style_pad_all(ctrl_bar, 0, 0);
|
||||
lv_obj_set_style_pad_gap(ctrl_bar, 0, 0);
|
||||
lv_obj_set_flex_flow(ctrl_bar, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_flex_align(ctrl_bar, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_remove_flag(ctrl_bar, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
|
||||
// Progress Bar (thin bar running along the top of control bar)
|
||||
g_ctx.bar_progress = lv_bar_create(g_ctx.player_wrapper);
|
||||
lv_obj_set_size(g_ctx.bar_progress, LV_PCT(100), 4);
|
||||
lv_obj_align_to(g_ctx.bar_progress, ctrl_bar, LV_ALIGN_OUT_TOP_MID, 0, 0);
|
||||
@@ -950,7 +966,8 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
||||
lv_bar_set_value(g_ctx.bar_progress, 0, LV_ANIM_OFF);
|
||||
lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x45475A), LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x89B4FA), LV_PART_INDICATOR);
|
||||
|
||||
|
||||
// Prev Button
|
||||
g_ctx.btn_prev = lv_button_create(ctrl_bar);
|
||||
lv_obj_set_size(g_ctx.btn_prev, 54, 30);
|
||||
lv_obj_set_style_radius(g_ctx.btn_prev, 15, 0);
|
||||
@@ -959,7 +976,8 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
||||
lv_label_set_text(lbl_prev, LV_SYMBOL_PREV);
|
||||
lv_obj_center(lbl_prev);
|
||||
lv_obj_add_event_cb(g_ctx.btn_prev, on_prev_click, LV_EVENT_CLICKED, &g_ctx);
|
||||
|
||||
|
||||
// Play/Pause Button
|
||||
g_ctx.btn_play_pause = lv_button_create(ctrl_bar);
|
||||
lv_obj_set_size(g_ctx.btn_play_pause, 94, 30);
|
||||
lv_obj_set_style_radius(g_ctx.btn_play_pause, 15, 0);
|
||||
@@ -969,7 +987,8 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
||||
lv_label_set_text(lbl_play_btn, LV_SYMBOL_PLAY " Play");
|
||||
lv_obj_center(lbl_play_btn);
|
||||
lv_obj_add_event_cb(g_ctx.btn_play_pause, on_play_pause_click, LV_EVENT_CLICKED, &g_ctx);
|
||||
|
||||
|
||||
// Next Button
|
||||
g_ctx.btn_next = lv_button_create(ctrl_bar);
|
||||
lv_obj_set_size(g_ctx.btn_next, 54, 30);
|
||||
lv_obj_set_style_radius(g_ctx.btn_next, 15, 0);
|
||||
@@ -982,20 +1001,26 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
||||
// Initial load
|
||||
g_ctx.state = STATE_IDLE;
|
||||
update_ui(&g_ctx);
|
||||
|
||||
|
||||
// Scan Books
|
||||
scan_books(&g_ctx);
|
||||
}
|
||||
|
||||
static void onHideApp(AppHandle app, void* data) {
|
||||
wait_for_playback_task_to_exit(&g_ctx);
|
||||
close_stream_if_open(&g_ctx);
|
||||
|
||||
|
||||
if (g_ctx.i2s_dev) {
|
||||
device_lock(g_ctx.i2s_dev);
|
||||
i2s_controller_reset(g_ctx.i2s_dev);
|
||||
device_unlock(g_ctx.i2s_dev);
|
||||
}
|
||||
|
||||
if (g_ctx.manifest_root) {
|
||||
cJSON_Delete(g_ctx.manifest_root);
|
||||
g_ctx.manifest_root = NULL;
|
||||
g_ctx.pages_array = NULL;
|
||||
}
|
||||
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
if (g_ctx.audio_buf) {
|
||||
heap_caps_free(g_ctx.audio_buf);
|
||||
|
||||
@@ -12,5 +12,5 @@ endif()
|
||||
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
|
||||
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
|
||||
|
||||
project(VoiceRecorder)
|
||||
tactility_project(VoiceRecorder)
|
||||
project(GameBoy)
|
||||
tactility_project(GameBoy)
|
||||
@@ -0,0 +1,96 @@
|
||||
# GameBoy Emulator (Tactility Prototype, No Audio)
|
||||
|
||||
DMG Game Boy emulator for Tactility side-loaded apps, using **Peanut-GB** (MIT) as CPU/LCD core.
|
||||
|
||||
## Status
|
||||
|
||||
- Prototype v0.1.0-dev – compiling draft focused on architecture, not yet production-hardened.
|
||||
- **No audio** (ENABLE_SOUND 0). Audio path stubbed for future MiniGB APU or i2s-driven implementation.
|
||||
- Supports MBC1/MBC2/MBC3/MBC5 via Peanut-GB.
|
||||
- ROM loader from SD card.
|
||||
- Save RAM persistence via `.sav` file next to ROM.
|
||||
- LVGL framebuffer: native 160x144 RGB565, integer-scaled if display large enough, centered on black background.
|
||||
- Input: on-screen D-pad + A/B + Start/Select + hardware keyboard arrows + LVGL key events (z= A, x= B).
|
||||
- Timer-driven at ~16ms (~60Hz) calling `gb_run_frame()`.
|
||||
|
||||
## ROM Location
|
||||
|
||||
- Scanned directory: `/sdcard/roms/gb/` for `*.gb`, `*.gbc`, `*.bin` (up to 64 entries).
|
||||
- Default quick-load: `/sdcard/roms/gb/default.gb` – if present on app show, autoloads and jumps directly to emulation.
|
||||
- Place your legally dumped ROMs there; no ROMs are bundled.
|
||||
|
||||
## Save RAM Path Design (stubbed + implemented minimal)
|
||||
|
||||
- Save file = ROM path with extension replaced by `.sav` (e.g. `/sdcard/roms/gb/tetris.gb` -> `/sdcard/roms/gb/tetris.sav`).
|
||||
- Loaded on ROM load, saved on:
|
||||
- switching back to menu
|
||||
- app hide
|
||||
- error recovery path
|
||||
- Size queried via `gb_get_save_size_s()` / `gb_get_save_size()`.
|
||||
- Future improvement: also mirror to app user-data dir (`tt_app_get_user_data_child_path`) if SD is read-only.
|
||||
|
||||
## Memory Considerations (ESP32-S3 / PSRAM)
|
||||
|
||||
- Large buffers allocated via `heap_caps_malloc(..., MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)` with fallback to internal.
|
||||
- ROM buffer: up to 2MB (MBC5 max-ish) – PSRAM preferred.
|
||||
- Cart RAM: variable, often 8KB-32KB, PSRAM.
|
||||
- Framebuffer: 160*144*2 = 46,080 bytes (~45KB) native RGB565. PSRAM preferred. No double buffering needed (line callback writes directly).
|
||||
- No huge heap allocations.
|
||||
- Emulator context `struct gb_s` is static inside AppCtx (~few KB).
|
||||
|
||||
## Controls / Input Mapping
|
||||
|
||||
| GB | On-screen | Keyboard | Remarks |
|
||||
|----|-----------|----------|---------|
|
||||
| D-pad | 4 arrow buttons | LV_KEY_ arrows | press/release tracked |
|
||||
| A | A button (right cluster) | z | |
|
||||
| B | B button (right cluster) | x | |
|
||||
| Start | Sta | Enter / Space | |
|
||||
| Select | Sel | Esc / Backspace | |
|
||||
| Touch | Quadrants not yet separated – buttons cover |
|
||||
|
||||
Future: touch quadrants mapping via `pointToQuadrant` like GameKitInput.
|
||||
|
||||
## LCD Rendering
|
||||
|
||||
- Peanut-GB calls `lcd_draw_line(gb, pixels[160], line)` per scanline.
|
||||
- `pixels` low 2 bits = shade 0-3.
|
||||
- Mapped to olive/gray palette RGB565 (editable).
|
||||
- Canvas buffer is the framebuffer itself.
|
||||
- Scale: if display resolution >= 320x432 => 2x, >=480x576 => 3x via LVGL transform scale (keeps native buffer).
|
||||
|
||||
## No-Audio Limitation
|
||||
|
||||
- `ENABLE_SOUND 0` – audio callbacks not compiled.
|
||||
- To add audio:
|
||||
1. Vendor MiniGB APU (`minigb_apu`) or similar.
|
||||
2. Implement `audio_read` / `audio_write` forwarding to APU.
|
||||
3. Define ENABLE_SOUND 1, include APU, create audio task similar to BookPlayer (i2s_controller).
|
||||
4. Feed APU samples in timer / separate task.
|
||||
|
||||
## Build
|
||||
|
||||
Same as other Tactility apps:
|
||||
|
||||
```
|
||||
. $IDF_PATH/export.sh
|
||||
export TACTILITY_SDK_PATH=...
|
||||
python3 tactility.py Apps/GameBoy build esp32s3 --local-sdk
|
||||
```
|
||||
|
||||
## Licensing
|
||||
|
||||
- App code: GPLv3 (same as Tactility Apps).
|
||||
- Peanut-GB vendored lib: MIT (Copyright (c) 2018-2023 Mahyar Koshkouei). License preserved in `Libraries/PeanutGB/peanut_gb.h`.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [ ] Improve input: add touch quadrant → D-pad, repeat timers for held buttons.
|
||||
- [ ] Add pause/resume UI, FPS display.
|
||||
- [ ] Add palette selector (auto_assign_palette logic from peanut_sdl).
|
||||
- [ ] Add file picker dialog (`tt_app_selectiondialog_start`) improvement + recursive folder browsing.
|
||||
- [ ] Audio: vendoring `minigb_apu` and creating I2S task.
|
||||
- [ ] Save state beyond cart RAM (full emu snapshot).
|
||||
- [ ] RTC persistence for MBC3 RTC games.
|
||||
- [ ] Error dialog via `tt_app_alertdialog_start`.
|
||||
- [ ] Validate with Cppcheck / clang-format and ESP-IDF build.
|
||||
@@ -2,5 +2,6 @@ file(GLOB_RECURSE SOURCE_FILES Source/*.c)
|
||||
|
||||
idf_component_register(
|
||||
SRCS ${SOURCE_FILES}
|
||||
INCLUDE_DIRS Source ../../../Libraries/PeanutGB
|
||||
REQUIRES TactilitySDK
|
||||
)
|
||||
@@ -0,0 +1,679 @@
|
||||
/**
|
||||
* @file main.c
|
||||
* @brief GameBoy DMG Emulator for Tactility (Peanut-GB prototype, no audio)
|
||||
*
|
||||
* MIT licensed Peanut-GB core vendored in Libraries/PeanutGB/peanut_gb.h
|
||||
* See app README for notes.
|
||||
*/
|
||||
|
||||
#include <tt_app.h>
|
||||
#include <tt_lvgl.h>
|
||||
#include <tt_lvgl_toolbar.h>
|
||||
#include <tt_app_alertdialog.h>
|
||||
#include <tt_lvgl_keyboard.h>
|
||||
|
||||
#include <lvgl.h>
|
||||
/* lv_image_cache_drop is not in public LVGL headers but exported by Tactility firmware */
|
||||
void lv_image_cache_drop(const void * src);
|
||||
#include <esp_log.h>
|
||||
|
||||
/* Board firmware 0.8.0-dev/IDF 5.3.2 does not export esp_log for side-loaded ELFs. */
|
||||
#undef ESP_LOGI
|
||||
#undef ESP_LOGW
|
||||
#undef ESP_LOGE
|
||||
#define ESP_LOGI(tag, fmt, ...) do { (void)(tag); } while (0)
|
||||
#define ESP_LOGW(tag, fmt, ...) do { (void)(tag); } while (0)
|
||||
#define ESP_LOGE(tag, fmt, ...) do { (void)(tag); } while (0)
|
||||
#include <esp_heap_caps.h>
|
||||
#include <esp_timer.h>
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <strings.h>
|
||||
#include <sys/stat.h>
|
||||
#include <dirent.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define ENABLE_SOUND 0
|
||||
#define ENABLE_LCD 1
|
||||
#include "peanut_gb.h"
|
||||
|
||||
#define TAG "GameBoy"
|
||||
#define DEFAULT_ROM_PATH "/data/roms/gb/default.gb"
|
||||
#define ROMS_DIR "/data/roms/gb"
|
||||
#define MAX_ROMS 64
|
||||
#define MAX_PATH 512
|
||||
#define MAX_ROM_SIZE (2 * 1024 * 1024)
|
||||
#define FRAME_W 160
|
||||
#define FRAME_H 144
|
||||
#define TICK_MS 16
|
||||
|
||||
/* RGB565 direct – no bitfield endian ambiguity */
|
||||
static const uint16_t GB_PALETTE[4] = {
|
||||
0xFFFF, // white
|
||||
0x8C51, // light gray ~ 0b10001 100010 10001 but pre-tuned
|
||||
0x4A49, // dark gray
|
||||
0x0000 // black
|
||||
};
|
||||
|
||||
typedef enum { APP_MODE_BROWSER, APP_MODE_EMU } AppMode;
|
||||
|
||||
typedef struct {
|
||||
char filename[MAX_PATH];
|
||||
char fullpath[MAX_PATH];
|
||||
} RomEntry;
|
||||
|
||||
typedef struct {
|
||||
struct gb_s gb;
|
||||
uint8_t* rom_data;
|
||||
size_t rom_size;
|
||||
uint8_t* cart_ram;
|
||||
size_t cart_ram_size;
|
||||
char rom_path[MAX_PATH];
|
||||
char rom_title[32];
|
||||
uint8_t joypad_state;
|
||||
|
||||
uint16_t* fb_native; /* RGB565 tightly packed 160*144, raw u16 avoids lv_color16_t bitfield */
|
||||
lv_draw_buf_t* fb_draw_buf; /* draw_buf that canvas src points to – owned by us so we can invalidate cache */
|
||||
lv_obj_t* canvas;
|
||||
lv_obj_t* root_wrapper;
|
||||
lv_obj_t* browser_wrapper;
|
||||
lv_obj_t* emu_wrapper;
|
||||
lv_obj_t* toolbar;
|
||||
lv_obj_t* status_label;
|
||||
lv_obj_t* rom_list;
|
||||
lv_obj_t* controls_cont;
|
||||
lv_timer_t* emu_timer;
|
||||
|
||||
RomEntry roms[MAX_ROMS];
|
||||
int rom_count;
|
||||
int selected_rom_idx;
|
||||
|
||||
lv_obj_t* btn_up;
|
||||
lv_obj_t* btn_down;
|
||||
lv_obj_t* btn_left;
|
||||
lv_obj_t* btn_right;
|
||||
lv_obj_t* btn_a;
|
||||
lv_obj_t* btn_b;
|
||||
lv_obj_t* btn_start;
|
||||
lv_obj_t* btn_select;
|
||||
|
||||
AppHandle app_handle;
|
||||
AppMode mode;
|
||||
bool emu_running;
|
||||
bool framebuffer_allocated;
|
||||
bool rom_loaded;
|
||||
uint32_t fps_frames;
|
||||
int64_t fps_last_us;
|
||||
uint32_t lines_drawn; /* debug: should be 144 per frame */
|
||||
} AppCtx;
|
||||
|
||||
typedef struct {
|
||||
AppCtx* ctx;
|
||||
uint8_t joypad_bit;
|
||||
} BtnUserData;
|
||||
|
||||
/* PSRAM helpers */
|
||||
static void* alloc_psram(size_t size) {
|
||||
void* p = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||
if (!p) p = heap_caps_malloc(size, MALLOC_CAP_8BIT);
|
||||
if (!p) p = malloc(size);
|
||||
return p;
|
||||
}
|
||||
|
||||
/* Peanut-GB callbacks */
|
||||
static uint8_t gb_rom_read(struct gb_s* gb, const uint_fast32_t addr) {
|
||||
AppCtx* ctx = (AppCtx*)gb->direct.priv;
|
||||
if (!ctx || !ctx->rom_data) return 0xFF;
|
||||
if (addr < ctx->rom_size) return ctx->rom_data[addr];
|
||||
return 0xFF;
|
||||
}
|
||||
static uint8_t gb_cart_ram_read(struct gb_s* gb, const uint_fast32_t addr) {
|
||||
AppCtx* ctx = (AppCtx*)gb->direct.priv;
|
||||
if (!ctx || !ctx->cart_ram) return 0xFF;
|
||||
if (addr < ctx->cart_ram_size) return ctx->cart_ram[addr];
|
||||
return 0xFF;
|
||||
}
|
||||
static void gb_cart_ram_write(struct gb_s* gb, const uint_fast32_t addr, const uint8_t val) {
|
||||
AppCtx* ctx = (AppCtx*)gb->direct.priv;
|
||||
if (!ctx || !ctx->cart_ram) return;
|
||||
if (addr < ctx->cart_ram_size) ctx->cart_ram[addr] = val;
|
||||
}
|
||||
static void gb_error_handler(struct gb_s* gb, const enum gb_error_e err, const uint16_t addr) {
|
||||
const char* err_str = "UNKNOWN";
|
||||
switch (err) {
|
||||
case GB_INVALID_OPCODE: err_str = "INVALID OPCODE"; break;
|
||||
case GB_INVALID_READ: err_str = "INVALID READ"; break;
|
||||
case GB_INVALID_WRITE: err_str = "INVALID WRITE"; break;
|
||||
default: break;
|
||||
}
|
||||
ESP_LOGE(TAG, "GB error %s (%d) @ %04X", err_str, err, addr);
|
||||
AppCtx* ctx = (AppCtx*)gb->direct.priv;
|
||||
if (ctx && ctx->cart_ram_size) {
|
||||
char sp[MAX_PATH];
|
||||
strncpy(sp, ctx->rom_path, MAX_PATH-1); sp[MAX_PATH-1]='\0';
|
||||
char* dot=strrchr(sp,'.'); char* sl=strrchr(sp,'/');
|
||||
if (dot && (!sl || dot>sl)) snprintf(dot, MAX_PATH-(dot-sp), ".sav"); else strncat(sp,".sav",MAX_PATH-strlen(sp)-1);
|
||||
FILE* f=fopen(sp,"wb"); if(f){fwrite(ctx->cart_ram,1,ctx->cart_ram_size,f); fclose(f);}
|
||||
}
|
||||
}
|
||||
static void lcd_draw_line(struct gb_s* gb, const uint8_t* pixels, const uint_fast8_t line) {
|
||||
AppCtx* ctx = (AppCtx*)gb->direct.priv;
|
||||
if (!ctx || !ctx->fb_native) return;
|
||||
if (line >= FRAME_H) return;
|
||||
uint16_t* dst = &ctx->fb_native[line * FRAME_W];
|
||||
for (int x=0;x<FRAME_W;x++) {
|
||||
uint8_t shade = pixels[x] & 0x03;
|
||||
dst[x] = GB_PALETTE[shade];
|
||||
}
|
||||
ctx->lines_drawn++;
|
||||
}
|
||||
|
||||
/* Save path */
|
||||
static void get_save_path(const char* rom_path, char* out, size_t out_len) {
|
||||
if (!rom_path || !out) return;
|
||||
strncpy(out, rom_path, out_len-1); out[out_len-1]='\0';
|
||||
char* dot=strrchr(out,'.'); char* sl=strrchr(out,'/');
|
||||
if (dot && (!sl || dot>sl)) { snprintf(dot, out_len-(dot-out), ".sav"); }
|
||||
else { strncat(out,".sav",out_len-strlen(out)-1); }
|
||||
}
|
||||
static void load_cart_ram(AppCtx* ctx) {
|
||||
if (!ctx || !ctx->rom_path[0]) return;
|
||||
char save_path[MAX_PATH];
|
||||
get_save_path(ctx->rom_path, save_path, sizeof(save_path));
|
||||
size_t save_sz=0;
|
||||
if (gb_get_save_size_s(&ctx->gb, &save_sz)!=0) save_sz=gb_get_save_size(&ctx->gb);
|
||||
if (save_sz==0) { ESP_LOGI(TAG,"No save RAM"); return; }
|
||||
if (ctx->cart_ram) { heap_caps_free(ctx->cart_ram); ctx->cart_ram=NULL; }
|
||||
ctx->cart_ram_size=save_sz;
|
||||
ctx->cart_ram=alloc_psram(save_sz);
|
||||
if (!ctx->cart_ram){ ESP_LOGE(TAG,"cart RAM alloc fail %zu",save_sz); ctx->cart_ram_size=0; return; }
|
||||
memset(ctx->cart_ram,0,save_sz);
|
||||
FILE* f=fopen(save_path,"rb");
|
||||
if (!f){ ESP_LOGI(TAG,"No save %s",save_path); return; }
|
||||
size_t r=fread(ctx->cart_ram,1,save_sz,f); fclose(f);
|
||||
ESP_LOGI(TAG,"Loaded save %s %zu/%zu",save_path,r,save_sz);
|
||||
}
|
||||
static void save_cart_ram(AppCtx* ctx) {
|
||||
if (!ctx || !ctx->cart_ram || ctx->cart_ram_size==0) return;
|
||||
if (!ctx->rom_path[0]) return;
|
||||
char save_path[MAX_PATH];
|
||||
get_save_path(ctx->rom_path, save_path, sizeof(save_path));
|
||||
FILE* f=fopen(save_path,"wb");
|
||||
if (!f){ ESP_LOGE(TAG,"Save open fail %s",save_path); return; }
|
||||
size_t w=fwrite(ctx->cart_ram,1,ctx->cart_ram_size,f); fclose(f);
|
||||
ESP_LOGI(TAG,"Saved RAM %s %zu",save_path,w);
|
||||
}
|
||||
|
||||
/* ROM loading */
|
||||
static bool load_rom_file(AppCtx* ctx, const char* path) {
|
||||
if (!ctx || !path) return false;
|
||||
ESP_LOGI(TAG,"Loading ROM %s",path);
|
||||
FILE* f=fopen(path,"rb");
|
||||
if (!f){ ESP_LOGE(TAG,"Open fail %s",path); return false; }
|
||||
fseek(f,0,SEEK_END); long sz=ftell(f); fseek(f,0,SEEK_SET);
|
||||
if (sz<=0 || sz>MAX_ROM_SIZE){ ESP_LOGE(TAG,"Bad size %ld %s",sz,path); fclose(f); return false; }
|
||||
uint8_t* buf=alloc_psram((size_t)sz);
|
||||
if (!buf){ ESP_LOGE(TAG,"Alloc fail %ld",sz); fclose(f); return false; }
|
||||
size_t read=fread(buf,1,(size_t)sz,f); fclose(f);
|
||||
if (read!=(size_t)sz){ ESP_LOGE(TAG,"Short read %zu vs %ld",read,sz); heap_caps_free(buf); return false; }
|
||||
|
||||
if (ctx->rom_data) { if (ctx->cart_ram) save_cart_ram(ctx); heap_caps_free(ctx->rom_data); }
|
||||
if (ctx->cart_ram){ heap_caps_free(ctx->cart_ram); ctx->cart_ram=NULL; ctx->cart_ram_size=0; }
|
||||
|
||||
ctx->rom_data=buf; ctx->rom_size=(size_t)sz;
|
||||
strncpy(ctx->rom_path,path,sizeof(ctx->rom_path)-1); ctx->rom_path[sizeof(ctx->rom_path)-1]='\0';
|
||||
memset(&ctx->gb,0,sizeof(ctx->gb));
|
||||
ctx->joypad_state=0xFF;
|
||||
enum gb_init_error_e err=gb_init(&ctx->gb, gb_rom_read, gb_cart_ram_read, gb_cart_ram_write, gb_error_handler, ctx);
|
||||
if (err!=GB_INIT_NO_ERROR){ ESP_LOGE(TAG,"gb_init %d",err); heap_caps_free(ctx->rom_data); ctx->rom_data=NULL; ctx->rom_size=0; return false; }
|
||||
gb_init_lcd(&ctx->gb, lcd_draw_line);
|
||||
load_cart_ram(ctx);
|
||||
gb_reset(&ctx->gb);
|
||||
char title[32]={0}; gb_get_rom_name(&ctx->gb, title); strncpy(ctx->rom_title,title,sizeof(ctx->rom_title)-1);
|
||||
ESP_LOGI(TAG,"ROM OK title='%s' size=%zu save=%zu",ctx->rom_title,ctx->rom_size,ctx->cart_ram_size);
|
||||
ctx->rom_loaded=true;
|
||||
return true;
|
||||
}
|
||||
static void scan_rom_dir(AppCtx* ctx) {
|
||||
ctx->rom_count=0;
|
||||
DIR* dir=opendir(ROMS_DIR);
|
||||
if (!dir){ ESP_LOGW(TAG,"ROMS dir missing %s",ROMS_DIR); return; }
|
||||
struct dirent* ent;
|
||||
while ((ent=readdir(dir))!=NULL && ctx->rom_count<MAX_ROMS){
|
||||
if (ent->d_name[0]=='.') continue;
|
||||
size_t len=strlen(ent->d_name);
|
||||
if (len<3) continue;
|
||||
bool is_gb = (strcasecmp(ent->d_name+len-3,".gb")==0) || (len>=4 && (strcasecmp(ent->d_name+len-4,".gbc")==0 || strcasecmp(ent->d_name+len-4,".bin")==0));
|
||||
if (!is_gb) continue;
|
||||
RomEntry* e=&ctx->roms[ctx->rom_count++];
|
||||
strncpy(e->filename, ent->d_name, sizeof(e->filename)-1); e->filename[sizeof(e->filename)-1]='\0';
|
||||
snprintf(e->fullpath, sizeof(e->fullpath), "%s/%s", ROMS_DIR, ent->d_name);
|
||||
}
|
||||
closedir(dir);
|
||||
ESP_LOGI(TAG,"Found %d ROMs",ctx->rom_count);
|
||||
}
|
||||
|
||||
/* Emu timer – THIS IS WHERE "FPS but no image" WAS: missing cache drop */
|
||||
static void emu_timer_cb(lv_timer_t* timer) {
|
||||
AppCtx* ctx=(AppCtx*)lv_timer_get_user_data(timer);
|
||||
if (!ctx || !ctx->emu_running || !ctx->rom_loaded || !ctx->canvas) return;
|
||||
ctx->lines_drawn = 0;
|
||||
ctx->gb.direct.joypad = ctx->joypad_state;
|
||||
gb_run_frame(&ctx->gb);
|
||||
|
||||
/* Critical fix: raw buffer mutated, LVGL image cache is stale.
|
||||
Without this, canvas shows whatever was first uploaded (gray/black) and FPS label keeps updating,
|
||||
giving "FPS but no game". */
|
||||
if (ctx->fb_draw_buf) {
|
||||
/* Invalidate both D-Cache (PSRAM) and LVGL image cache */
|
||||
lv_draw_buf_invalidate_cache(ctx->fb_draw_buf, NULL);
|
||||
lv_image_cache_drop(ctx->fb_draw_buf);
|
||||
/* Also invalidate area via the canvas src buf if different object */
|
||||
if (ctx->canvas) {
|
||||
lv_draw_buf_t* c_db = lv_canvas_get_draw_buf(ctx->canvas);
|
||||
if (c_db && c_db != ctx->fb_draw_buf) {
|
||||
lv_draw_buf_invalidate_cache(c_db, NULL);
|
||||
lv_image_cache_drop(c_db);
|
||||
}
|
||||
}
|
||||
}
|
||||
lv_obj_invalidate(ctx->canvas);
|
||||
|
||||
ctx->fps_frames++;
|
||||
int64_t now = esp_timer_get_time();
|
||||
if (ctx->fps_last_us == 0) ctx->fps_last_us = now;
|
||||
int64_t elapsed = now - ctx->fps_last_us;
|
||||
if (elapsed >= 1000000) {
|
||||
uint32_t fps = (uint32_t)((ctx->fps_frames * 1000000ULL) / (uint64_t)elapsed);
|
||||
if (ctx->status_label) {
|
||||
lv_label_set_text_fmt(ctx->status_label, "GB: %s FPS:%lu L:%lu", ctx->rom_title[0] ? ctx->rom_title : "GameBoy", (unsigned long)fps, (unsigned long)ctx->lines_drawn);
|
||||
}
|
||||
printf("GAMEBOY_FPS %lu LINES %lu\n", (unsigned long)fps, (unsigned long)ctx->lines_drawn);
|
||||
ctx->fps_frames = 0;
|
||||
ctx->fps_last_us = now;
|
||||
}
|
||||
}
|
||||
|
||||
/* Input helpers */
|
||||
static void set_joypad_bit(AppCtx* ctx, uint8_t bit, bool pressed) {
|
||||
if (!ctx) return;
|
||||
if (pressed) ctx->joypad_state &= (uint8_t)~bit;
|
||||
else ctx->joypad_state |= bit;
|
||||
}
|
||||
static void input_down_cb(lv_event_t* e){
|
||||
BtnUserData* ud=(BtnUserData*)lv_event_get_user_data(e);
|
||||
if (!ud) return;
|
||||
set_joypad_bit(ud->ctx, ud->joypad_bit, true);
|
||||
}
|
||||
static void input_up_cb(lv_event_t* e){
|
||||
BtnUserData* ud=(BtnUserData*)lv_event_get_user_data(e);
|
||||
if (!ud) return;
|
||||
set_joypad_bit(ud->ctx, ud->joypad_bit, false);
|
||||
}
|
||||
static void key_press_cb(lv_event_t* e){
|
||||
AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e);
|
||||
uint32_t key=lv_event_get_key(e);
|
||||
switch(key){
|
||||
case LV_KEY_UP: set_joypad_bit(ctx, JOYPAD_UP, true); break;
|
||||
case LV_KEY_DOWN: set_joypad_bit(ctx, JOYPAD_DOWN, true); break;
|
||||
case LV_KEY_LEFT: set_joypad_bit(ctx, JOYPAD_LEFT, true); break;
|
||||
case LV_KEY_RIGHT: set_joypad_bit(ctx, JOYPAD_RIGHT, true); break;
|
||||
case LV_KEY_ENTER: set_joypad_bit(ctx, JOYPAD_START, true); break;
|
||||
case LV_KEY_ESC: set_joypad_bit(ctx, JOYPAD_SELECT, true); break;
|
||||
default: {
|
||||
if (key== (uint32_t)'z' || key== (uint32_t)'Z') set_joypad_bit(ctx, JOYPAD_A, true);
|
||||
else if (key== (uint32_t)'x' || key== (uint32_t)'X') set_joypad_bit(ctx, JOYPAD_B, true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Forward declarations for UI switching */
|
||||
static void build_browser_ui(AppCtx* ctx, lv_obj_t* parent);
|
||||
static void build_emu_ui(AppCtx* ctx, lv_obj_t* parent);
|
||||
static void switch_to_browser(AppCtx* ctx);
|
||||
static void switch_to_emu(AppCtx* ctx);
|
||||
|
||||
/* ROM selection events */
|
||||
static void rom_button_event(lv_event_t* e){
|
||||
AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e);
|
||||
lv_obj_t* btn=lv_event_get_target_obj(e);
|
||||
int* pIdx=(int*)lv_obj_get_user_data(btn);
|
||||
if (!pIdx) return;
|
||||
ctx->selected_rom_idx=*pIdx;
|
||||
if (ctx->selected_rom_idx<0 || ctx->selected_rom_idx>=ctx->rom_count) return;
|
||||
const char* path=ctx->roms[ctx->selected_rom_idx].fullpath;
|
||||
if (load_rom_file(ctx, path)){
|
||||
switch_to_emu(ctx);
|
||||
} else {
|
||||
if (ctx->status_label) lv_label_set_text_fmt(ctx->status_label, "Failed: %s", ctx->roms[ctx->selected_rom_idx].filename);
|
||||
}
|
||||
}
|
||||
static void default_rom_event(lv_event_t* e){
|
||||
AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e);
|
||||
if (load_rom_file(ctx, DEFAULT_ROM_PATH)){
|
||||
switch_to_emu(ctx);
|
||||
} else {
|
||||
if (ctx->status_label) lv_label_set_text(ctx->status_label, "default.gb not found in /data/roms/gb/");
|
||||
}
|
||||
}
|
||||
static void back_to_menu_event(lv_event_t* e){
|
||||
AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e);
|
||||
switch_to_browser(ctx);
|
||||
}
|
||||
|
||||
/* UI builders */
|
||||
static void build_browser_ui(AppCtx* ctx, lv_obj_t* parent){
|
||||
lv_obj_clean(parent);
|
||||
ctx->rom_list=NULL;
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_all(parent, 4, 0);
|
||||
lv_obj_set_style_bg_color(parent, lv_color_black(), 0);
|
||||
|
||||
lv_obj_t* info=lv_label_create(parent);
|
||||
lv_label_set_text_fmt(info, "GameBoy (no audio) - Peanut-GB\nPlace ROMs in %s\nDefault: %s\nFound %d ROMs", ROMS_DIR, DEFAULT_ROM_PATH, ctx->rom_count);
|
||||
lv_label_set_long_mode(info, LV_LABEL_LONG_WRAP);
|
||||
lv_obj_set_width(info, LV_PCT(100));
|
||||
lv_obj_set_style_text_color(info, lv_color_white(), 0);
|
||||
|
||||
ctx->status_label=lv_label_create(parent);
|
||||
lv_label_set_text(ctx->status_label, "Select a ROM to start");
|
||||
lv_obj_set_style_text_color(ctx->status_label, lv_palette_main(LV_PALETTE_ORANGE), 0);
|
||||
|
||||
lv_obj_t* list=lv_list_create(parent);
|
||||
lv_obj_set_width(list, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(list, 1);
|
||||
ctx->rom_list=list;
|
||||
|
||||
if (ctx->rom_count==0){
|
||||
lv_obj_t* lbl=lv_label_create(parent);
|
||||
lv_label_set_text(lbl, "No ROMs found in /data/roms/gb\nPut .gb files there.");
|
||||
lv_obj_set_style_text_color(lbl, lv_color_white(), 0);
|
||||
} else {
|
||||
for (int i=0;i<ctx->rom_count;i++){
|
||||
lv_obj_t* btn=lv_list_add_btn(list, LV_SYMBOL_FILE, ctx->roms[i].filename);
|
||||
int* idx=(int*)malloc(sizeof(int)); *idx=i;
|
||||
lv_obj_set_user_data(btn, idx);
|
||||
lv_obj_add_event_cb(btn, rom_button_event, LV_EVENT_CLICKED, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
lv_obj_t* default_btn=lv_btn_create(parent);
|
||||
lv_obj_set_width(default_btn, LV_PCT(100));
|
||||
lv_obj_t* dlbl=lv_label_create(default_btn);
|
||||
lv_label_set_text(dlbl, "Try default.gb");
|
||||
lv_obj_center(dlbl);
|
||||
lv_obj_add_event_cb(default_btn, default_rom_event, LV_EVENT_CLICKED, ctx);
|
||||
}
|
||||
|
||||
static void build_emu_ui(AppCtx* ctx, lv_obj_t* parent){
|
||||
lv_obj_clean(parent);
|
||||
lv_obj_set_style_bg_color(parent, lv_color_black(), 0);
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_all(parent, 0, 0);
|
||||
lv_obj_set_style_pad_row(parent, 0, 0);
|
||||
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
lv_obj_t* info_bar=lv_obj_create(parent);
|
||||
lv_obj_set_width(info_bar, LV_PCT(100));
|
||||
lv_obj_set_height(info_bar, LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(info_bar, 2, 0);
|
||||
lv_obj_set_style_border_width(info_bar, 0, 0);
|
||||
lv_obj_set_flex_flow(info_bar, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_style_bg_color(info_bar, lv_color_hex(0x222222), 0);
|
||||
|
||||
lv_obj_t* title_lbl=lv_label_create(info_bar);
|
||||
ctx->status_label = title_lbl;
|
||||
lv_label_set_text_fmt(title_lbl, "GB: %s FPS:--", ctx->rom_title[0]?ctx->rom_title:"GameBoy");
|
||||
lv_obj_set_style_text_color(title_lbl, lv_color_white(), 0);
|
||||
|
||||
lv_obj_t* spacer=lv_obj_create(info_bar);
|
||||
lv_obj_set_style_bg_opa(spacer, LV_OPA_TRANSP,0);
|
||||
lv_obj_set_style_border_width(spacer,0,0);
|
||||
lv_obj_set_flex_grow(spacer,1);
|
||||
|
||||
lv_obj_t* back_btn=lv_btn_create(info_bar);
|
||||
lv_obj_set_size(back_btn, 60, 28);
|
||||
lv_obj_t* bl=lv_label_create(back_btn); lv_label_set_text(bl,"Menu"); lv_obj_center(bl);
|
||||
lv_obj_add_event_cb(back_btn, back_to_menu_event, LV_EVENT_CLICKED, ctx);
|
||||
|
||||
lv_obj_t* canvas_cont=lv_obj_create(parent);
|
||||
lv_obj_set_width(canvas_cont, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(canvas_cont,1);
|
||||
lv_obj_set_style_bg_color(canvas_cont, lv_color_black(),0);
|
||||
lv_obj_set_style_border_width(canvas_cont,0,0);
|
||||
lv_obj_set_style_pad_all(canvas_cont,2,0);
|
||||
lv_obj_set_flex_flow(canvas_cont, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_flex_align(canvas_cont, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_remove_flag(canvas_cont, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
if (!ctx->fb_native){
|
||||
size_t fb_bytes=FRAME_W*FRAME_H*sizeof(uint16_t);
|
||||
ctx->fb_native=(uint16_t*)alloc_psram(fb_bytes);
|
||||
if (ctx->fb_native){
|
||||
ctx->framebuffer_allocated=true;
|
||||
memset(ctx->fb_native,0,fb_bytes);
|
||||
/* Initial grey fill so we can visually confirm buffer ownership even before first gb_run_frame */
|
||||
for(int i=0;i<FRAME_W*FRAME_H;i++) ctx->fb_native[i]=0x4208;
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx->fb_native){
|
||||
lv_coord_t disp_w = lv_display_get_horizontal_resolution(NULL);
|
||||
lv_coord_t disp_h = lv_display_get_vertical_resolution(NULL);
|
||||
int avail_h = disp_h - 160;
|
||||
int avail_w = disp_w - 8;
|
||||
int scale = 1;
|
||||
if (avail_w >= FRAME_W * 3 && avail_h >= FRAME_H * 3) scale = 3;
|
||||
else if (avail_w >= FRAME_W * 2 && avail_h >= FRAME_H * 2) scale = 2;
|
||||
|
||||
ctx->canvas = lv_canvas_create(canvas_cont);
|
||||
/* lv_canvas_set_buffer creates internal static_buf header from our raw ptr */
|
||||
lv_canvas_set_buffer(ctx->canvas, ctx->fb_native, FRAME_W, FRAME_H, LV_COLOR_FORMAT_RGB565);
|
||||
ctx->fb_draw_buf = lv_canvas_get_draw_buf(ctx->canvas);
|
||||
if (ctx->fb_draw_buf && ctx->fb_draw_buf->data) {
|
||||
/* Force fresh cache state */
|
||||
lv_draw_buf_invalidate_cache(ctx->fb_draw_buf, NULL);
|
||||
lv_image_cache_drop(ctx->fb_draw_buf);
|
||||
}
|
||||
|
||||
if (scale > 1) {
|
||||
lv_image_set_scale(ctx->canvas, (uint32_t)(256 * scale));
|
||||
lv_image_set_pivot(ctx->canvas, FRAME_W / 2, FRAME_H / 2);
|
||||
}
|
||||
lv_obj_set_style_border_width(ctx->canvas, 1, 0);
|
||||
lv_obj_set_style_border_color(ctx->canvas, lv_color_hex(0x444444), 0);
|
||||
lv_obj_center(ctx->canvas);
|
||||
} else {
|
||||
lv_obj_t* err=lv_label_create(canvas_cont); lv_label_set_text(err,"FB alloc failed"); lv_obj_set_style_text_color(err, lv_color_white(),0);
|
||||
}
|
||||
|
||||
lv_obj_t* ctrl=lv_obj_create(parent);
|
||||
ctx->controls_cont=ctrl;
|
||||
lv_obj_set_width(ctrl, LV_PCT(100));
|
||||
lv_obj_set_height(ctrl, 110);
|
||||
lv_obj_set_style_pad_all(ctrl,2,0);
|
||||
lv_obj_set_style_bg_color(ctrl, lv_color_hex(0x111111),0);
|
||||
lv_obj_set_style_border_width(ctrl,0,0);
|
||||
lv_obj_set_flex_flow(ctrl, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_flex_align(ctrl, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
|
||||
static BtnUserData btn_ud[8];
|
||||
static bool ud_init=false;
|
||||
if (!ud_init){ memset(btn_ud,0,sizeof(btn_ud)); ud_init=true; }
|
||||
|
||||
lv_obj_t* dpad=lv_obj_create(ctrl);
|
||||
lv_obj_set_size(dpad,96,96);
|
||||
lv_obj_set_style_bg_opa(dpad,LV_OPA_TRANSP,0);
|
||||
lv_obj_set_style_border_width(dpad,0,0);
|
||||
lv_obj_set_style_pad_all(dpad,0,0);
|
||||
|
||||
lv_obj_t* up=lv_btn_create(dpad); lv_obj_set_size(up,32,32); lv_obj_set_pos(up,32,0);
|
||||
lv_obj_t* left=lv_btn_create(dpad); lv_obj_set_size(left,32,32); lv_obj_set_pos(left,0,32);
|
||||
lv_obj_t* right=lv_btn_create(dpad); lv_obj_set_size(right,32,32); lv_obj_set_pos(right,64,32);
|
||||
lv_obj_t* down=lv_btn_create(dpad); lv_obj_set_size(down,32,32); lv_obj_set_pos(down,32,64);
|
||||
lv_obj_t* lbl; lbl=lv_label_create(up); lv_label_set_text(lbl, LV_SYMBOL_UP); lv_obj_center(lbl);
|
||||
lbl=lv_label_create(down); lv_label_set_text(lbl, LV_SYMBOL_DOWN); lv_obj_center(lbl);
|
||||
lbl=lv_label_create(left); lv_label_set_text(lbl, LV_SYMBOL_LEFT); lv_obj_center(lbl);
|
||||
lbl=lv_label_create(right); lv_label_set_text(lbl, LV_SYMBOL_RIGHT); lv_obj_center(lbl);
|
||||
|
||||
ctx->btn_up=up; ctx->btn_down=down; ctx->btn_left=left; ctx->btn_right=right;
|
||||
btn_ud[0].ctx=ctx; btn_ud[0].joypad_bit=JOYPAD_UP; lv_obj_add_event_cb(up, input_down_cb, LV_EVENT_PRESSED, &btn_ud[0]); lv_obj_add_event_cb(up, input_up_cb, LV_EVENT_RELEASED, &btn_ud[0]); lv_obj_add_event_cb(up, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[0]);
|
||||
btn_ud[1].ctx=ctx; btn_ud[1].joypad_bit=JOYPAD_DOWN; lv_obj_add_event_cb(down, input_down_cb, LV_EVENT_PRESSED, &btn_ud[1]); lv_obj_add_event_cb(down, input_up_cb, LV_EVENT_RELEASED, &btn_ud[1]); lv_obj_add_event_cb(down, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[1]);
|
||||
btn_ud[2].ctx=ctx; btn_ud[2].joypad_bit=JOYPAD_LEFT; lv_obj_add_event_cb(left, input_down_cb, LV_EVENT_PRESSED, &btn_ud[2]); lv_obj_add_event_cb(left, input_up_cb, LV_EVENT_RELEASED, &btn_ud[2]); lv_obj_add_event_cb(left, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[2]);
|
||||
btn_ud[3].ctx=ctx; btn_ud[3].joypad_bit=JOYPAD_RIGHT;lv_obj_add_event_cb(right, input_down_cb, LV_EVENT_PRESSED, &btn_ud[3]); lv_obj_add_event_cb(right, input_up_cb, LV_EVENT_RELEASED, &btn_ud[3]); lv_obj_add_event_cb(right, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[3]);
|
||||
|
||||
lv_obj_t* center_col=lv_obj_create(ctrl);
|
||||
lv_obj_set_size(center_col,64,96);
|
||||
lv_obj_set_style_bg_opa(center_col,LV_OPA_TRANSP,0);
|
||||
lv_obj_set_style_border_width(center_col,0,0);
|
||||
lv_obj_set_flex_flow(center_col, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(center_col, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_row(center_col,4,0);
|
||||
|
||||
lv_obj_t* sel_btn=lv_btn_create(center_col); lv_obj_set_size(sel_btn,60,28); lbl=lv_label_create(sel_btn); lv_label_set_text(lbl,"Sel"); lv_obj_center(lbl);
|
||||
lv_obj_t* sta_btn=lv_btn_create(center_col); lv_obj_set_size(sta_btn,60,28); lbl=lv_label_create(sta_btn); lv_label_set_text(lbl,"Sta"); lv_obj_center(lbl);
|
||||
ctx->btn_select=sel_btn; ctx->btn_start=sta_btn;
|
||||
btn_ud[4].ctx=ctx; btn_ud[4].joypad_bit=JOYPAD_SELECT; lv_obj_add_event_cb(sel_btn, input_down_cb, LV_EVENT_PRESSED, &btn_ud[4]); lv_obj_add_event_cb(sel_btn, input_up_cb, LV_EVENT_RELEASED, &btn_ud[4]); lv_obj_add_event_cb(sel_btn, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[4]);
|
||||
btn_ud[5].ctx=ctx; btn_ud[5].joypad_bit=JOYPAD_START; lv_obj_add_event_cb(sta_btn, input_down_cb, LV_EVENT_PRESSED, &btn_ud[5]); lv_obj_add_event_cb(sta_btn, input_up_cb, LV_EVENT_RELEASED, &btn_ud[5]); lv_obj_add_event_cb(sta_btn, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[5]);
|
||||
|
||||
lv_obj_t* ab=lv_obj_create(ctrl);
|
||||
lv_obj_set_size(ab,96,96);
|
||||
lv_obj_set_style_bg_opa(ab,LV_OPA_TRANSP,0);
|
||||
lv_obj_set_style_border_width(ab,0,0);
|
||||
lv_obj_set_style_pad_all(ab,0,0);
|
||||
lv_obj_t* b_btn=lv_btn_create(ab); lv_obj_set_size(b_btn,40,40); lv_obj_set_pos(b_btn,0,24); lv_obj_set_style_radius(b_btn,20,0);
|
||||
lv_obj_t* a_btn=lv_btn_create(ab); lv_obj_set_size(a_btn,40,40); lv_obj_set_pos(a_btn,48,8); lv_obj_set_style_radius(a_btn,20,0);
|
||||
lbl=lv_label_create(b_btn); lv_label_set_text(lbl,"B"); lv_obj_center(lbl);
|
||||
lbl=lv_label_create(a_btn); lv_label_set_text(lbl,"A"); lv_obj_center(lbl);
|
||||
ctx->btn_a=a_btn; ctx->btn_b=b_btn;
|
||||
btn_ud[6].ctx=ctx; btn_ud[6].joypad_bit=JOYPAD_B; lv_obj_add_event_cb(b_btn, input_down_cb, LV_EVENT_PRESSED, &btn_ud[6]); lv_obj_add_event_cb(b_btn, input_up_cb, LV_EVENT_RELEASED, &btn_ud[6]); lv_obj_add_event_cb(b_btn, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[6]);
|
||||
btn_ud[7].ctx=ctx; btn_ud[7].joypad_bit=JOYPAD_A; lv_obj_add_event_cb(a_btn, input_down_cb, LV_EVENT_PRESSED, &btn_ud[7]); lv_obj_add_event_cb(a_btn, input_up_cb, LV_EVENT_RELEASED, &btn_ud[7]); lv_obj_add_event_cb(a_btn, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[7]);
|
||||
|
||||
lv_obj_add_event_cb(parent, key_press_cb, LV_EVENT_KEY, ctx);
|
||||
if (tt_lvgl_hardware_keyboard_is_available()){
|
||||
lv_group_t* g=lv_group_get_default();
|
||||
if (g){ lv_group_add_obj(g, parent); lv_group_focus_obj(parent); lv_group_set_editing(g, true); }
|
||||
}
|
||||
|
||||
if (ctx->emu_timer){ lv_timer_delete(ctx->emu_timer); ctx->emu_timer=NULL; }
|
||||
ctx->fps_frames = 0;
|
||||
ctx->fps_last_us = esp_timer_get_time();
|
||||
ctx->lines_drawn = 0;
|
||||
ctx->emu_timer=lv_timer_create(emu_timer_cb, TICK_MS, ctx);
|
||||
ctx->emu_running=true;
|
||||
ctx->mode=APP_MODE_EMU;
|
||||
}
|
||||
|
||||
static void switch_to_browser(AppCtx* ctx){
|
||||
if (ctx->emu_timer){ lv_timer_delete(ctx->emu_timer); ctx->emu_timer=NULL; }
|
||||
ctx->emu_running=false;
|
||||
save_cart_ram(ctx);
|
||||
ctx->mode=APP_MODE_BROWSER;
|
||||
if (!ctx->browser_wrapper || !lv_obj_is_valid(ctx->browser_wrapper)) return;
|
||||
if (ctx->emu_wrapper) lv_obj_add_flag(ctx->emu_wrapper, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_remove_flag(ctx->browser_wrapper, LV_OBJ_FLAG_HIDDEN);
|
||||
scan_rom_dir(ctx);
|
||||
build_browser_ui(ctx, ctx->browser_wrapper);
|
||||
}
|
||||
static void switch_to_emu(AppCtx* ctx){
|
||||
if (!ctx->rom_loaded) return;
|
||||
if (ctx->browser_wrapper) lv_obj_add_flag(ctx->browser_wrapper, LV_OBJ_FLAG_HIDDEN);
|
||||
if (!ctx->emu_wrapper) return;
|
||||
lv_obj_remove_flag(ctx->emu_wrapper, LV_OBJ_FLAG_HIDDEN);
|
||||
build_emu_ui(ctx, ctx->emu_wrapper);
|
||||
}
|
||||
|
||||
/* Lifecycle */
|
||||
static void* create_data(void){
|
||||
AppCtx* ctx=(AppCtx*)calloc(1,sizeof(AppCtx));
|
||||
if (ctx){ ctx->joypad_state=0xFF; ctx->mode=APP_MODE_BROWSER; ctx->selected_rom_idx=-1; }
|
||||
return ctx;
|
||||
}
|
||||
static void destroy_data(void* data){
|
||||
AppCtx* ctx=(AppCtx*)data;
|
||||
if (!ctx) return;
|
||||
if (ctx->emu_timer) lv_timer_delete(ctx->emu_timer);
|
||||
if (ctx->fb_native) heap_caps_free(ctx->fb_native);
|
||||
if (ctx->rom_data) heap_caps_free(ctx->rom_data);
|
||||
if (ctx->cart_ram) heap_caps_free(ctx->cart_ram);
|
||||
free(ctx);
|
||||
}
|
||||
static void on_create(AppHandle app, void* data){
|
||||
AppCtx* ctx=(AppCtx*)data; if (ctx) ctx->app_handle=app;
|
||||
}
|
||||
static void on_destroy(AppHandle app, void* data){ (void)app; (void)data; }
|
||||
static void on_show(AppHandle app, void* data, lv_obj_t* parent){
|
||||
AppCtx* ctx=(AppCtx*)data; if (!ctx) return;
|
||||
ctx->app_handle=app;
|
||||
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_all(parent,0,0);
|
||||
lv_obj_set_style_pad_row(parent,0,0);
|
||||
lv_obj_set_style_bg_color(parent, lv_color_black(),0);
|
||||
ctx->toolbar=tt_lvgl_toolbar_create_for_app(parent, app);
|
||||
lv_obj_set_style_bg_color(ctx->toolbar, lv_color_hex(0x111111),0);
|
||||
|
||||
ctx->root_wrapper=lv_obj_create(parent);
|
||||
lv_obj_set_width(ctx->root_wrapper, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(ctx->root_wrapper,1);
|
||||
lv_obj_set_style_pad_all(ctx->root_wrapper,0,0);
|
||||
lv_obj_set_style_border_width(ctx->root_wrapper,0,0);
|
||||
lv_obj_set_style_bg_color(ctx->root_wrapper, lv_color_black(),0);
|
||||
lv_obj_set_flex_flow(ctx->root_wrapper, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_remove_flag(ctx->root_wrapper, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
ctx->browser_wrapper=lv_obj_create(ctx->root_wrapper);
|
||||
lv_obj_set_width(ctx->browser_wrapper, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(ctx->browser_wrapper,1);
|
||||
lv_obj_set_style_pad_all(ctx->browser_wrapper,0,0);
|
||||
lv_obj_set_style_border_width(ctx->browser_wrapper,0,0);
|
||||
|
||||
ctx->emu_wrapper=lv_obj_create(ctx->root_wrapper);
|
||||
lv_obj_set_width(ctx->emu_wrapper, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(ctx->emu_wrapper,1);
|
||||
lv_obj_set_style_pad_all(ctx->emu_wrapper,0,0);
|
||||
lv_obj_set_style_border_width(ctx->emu_wrapper,0,0);
|
||||
lv_obj_add_flag(ctx->emu_wrapper, LV_OBJ_FLAG_HIDDEN);
|
||||
|
||||
scan_rom_dir(ctx);
|
||||
struct stat st;
|
||||
bool default_exists=(stat(DEFAULT_ROM_PATH,&st)==0);
|
||||
if (default_exists){
|
||||
if (load_rom_file(ctx, DEFAULT_ROM_PATH)){
|
||||
build_browser_ui(ctx, ctx->browser_wrapper);
|
||||
switch_to_emu(ctx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
build_browser_ui(ctx, ctx->browser_wrapper);
|
||||
lv_obj_remove_flag(ctx->browser_wrapper, LV_OBJ_FLAG_HIDDEN);
|
||||
ctx->mode=APP_MODE_BROWSER;
|
||||
}
|
||||
static void on_hide(AppHandle app, void* data){
|
||||
(void)app;
|
||||
AppCtx* ctx=(AppCtx*)data; if (!ctx) return;
|
||||
if (ctx->emu_timer){ lv_timer_delete(ctx->emu_timer); ctx->emu_timer=NULL; }
|
||||
ctx->emu_running=false;
|
||||
if (ctx->rom_loaded) save_cart_ram(ctx);
|
||||
ctx->canvas=NULL; ctx->fb_draw_buf=NULL; ctx->toolbar=NULL; ctx->root_wrapper=NULL; ctx->browser_wrapper=NULL; ctx->emu_wrapper=NULL;
|
||||
ctx->status_label=NULL; ctx->rom_list=NULL; ctx->controls_cont=NULL;
|
||||
ctx->btn_up=ctx->btn_down=ctx->btn_left=ctx->btn_right=NULL;
|
||||
ctx->btn_a=ctx->btn_b=ctx->btn_start=ctx->btn_select=NULL;
|
||||
ctx->app_handle=NULL;
|
||||
}
|
||||
|
||||
int main(int argc, char* argv[]){
|
||||
(void)argc; (void)argv;
|
||||
tt_app_register((AppRegistration){
|
||||
.createData=create_data,
|
||||
.destroyData=destroy_data,
|
||||
.onCreate=on_create,
|
||||
.onDestroy=on_destroy,
|
||||
.onShow=on_show,
|
||||
.onHide=on_hide
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.8.0-dev
|
||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
[app]
|
||||
id=one.tactility.gameboy
|
||||
versionName=0.1.0-dev
|
||||
versionCode=1
|
||||
name=GameBoy
|
||||
description=DMG Game Boy emulator (no audio prototype, Peanut-GB)
|
||||
@@ -3,8 +3,7 @@
|
||||
#include <tt_lvgl_toolbar.h>
|
||||
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/drivers/audio_stream.h>
|
||||
#include <tactility/drivers/audio_codec.h>
|
||||
#include <tactility/drivers/i2s_controller.h>
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
@@ -28,8 +27,7 @@ typedef enum {
|
||||
} PlaybackState;
|
||||
|
||||
typedef struct {
|
||||
struct Device* stream_dev;
|
||||
AudioStreamHandle stream_handle;
|
||||
struct Device* i2s_dev;
|
||||
char filepath[512];
|
||||
PlaybackState state;
|
||||
|
||||
@@ -121,17 +119,17 @@ static void mp3_playback_task(void* arg) {
|
||||
ctx->eof = false;
|
||||
ctx->sample_rate = 0;
|
||||
ctx->channels = 0;
|
||||
ctx->stream_handle = NULL;
|
||||
|
||||
ESP_LOGI(TAG, "Starting MP3 playback: %s (size: %d bytes)", ctx->filepath, ctx->file_size);
|
||||
|
||||
while (ctx->state != STATE_IDLE) {
|
||||
if (ctx->state == STATE_PAUSED) {
|
||||
if (ctx->stream_handle != NULL) {
|
||||
// Close stream immediately on pause to stop DMA
|
||||
audio_stream_close(ctx->stream_handle);
|
||||
ctx->stream_handle = NULL;
|
||||
// Clear cached format to force re-open on resume
|
||||
if (ctx->sample_rate != 0) {
|
||||
// Reset I2S immediately on pause to stop DMA loops
|
||||
device_lock(ctx->i2s_dev);
|
||||
i2s_controller_reset(ctx->i2s_dev);
|
||||
device_unlock(ctx->i2s_dev);
|
||||
// Clear configuration cache to force reconfig on resume
|
||||
ctx->sample_rate = 0;
|
||||
ctx->channels = 0;
|
||||
}
|
||||
@@ -174,30 +172,30 @@ static void mp3_playback_task(void* arg) {
|
||||
memmove(ctx->input_buf, ctx->input_buf + consumed, ctx->buffered_bytes);
|
||||
|
||||
if (samples > 0) {
|
||||
// Configure audio-stream if format changed
|
||||
// Configure I2S if format changed
|
||||
if (ctx->sample_rate != info.hz || ctx->channels != info.channels) {
|
||||
if (ctx->stream_handle != NULL) {
|
||||
audio_stream_close(ctx->stream_handle);
|
||||
ctx->stream_handle = NULL;
|
||||
}
|
||||
|
||||
struct AudioStreamConfig config = {
|
||||
struct I2sConfig config = {
|
||||
.communication_format = I2S_FORMAT_STAND_I2S,
|
||||
.sample_rate = (uint32_t)info.hz,
|
||||
.bits_per_sample = 16,
|
||||
.channels = (uint8_t)info.channels
|
||||
.channel_left = 0,
|
||||
.channel_right = (info.channels == 2) ? 1 : I2S_CHANNEL_NONE
|
||||
};
|
||||
|
||||
error_t err = audio_stream_open_output(ctx->stream_dev, &config, &ctx->stream_handle);
|
||||
|
||||
device_lock(ctx->i2s_dev);
|
||||
error_t err = i2s_controller_set_config(ctx->i2s_dev, &config);
|
||||
device_unlock(ctx->i2s_dev);
|
||||
|
||||
if (err != ERROR_NONE) {
|
||||
ESP_LOGE(TAG, "Failed to open audio stream: %d", err);
|
||||
ESP_LOGE(TAG, "Failed to set config: %d", err);
|
||||
break;
|
||||
}
|
||||
ctx->sample_rate = info.hz;
|
||||
ctx->channels = info.channels;
|
||||
ESP_LOGI(TAG, "Audio stream opened: %d Hz, %d channels", info.hz, info.channels);
|
||||
ESP_LOGI(TAG, "I2S configured: %d Hz, %d channels", info.hz, info.channels);
|
||||
}
|
||||
|
||||
// Adjust volume (after resampler)
|
||||
// Adjust volume
|
||||
int vol = ctx->volume;
|
||||
int16_t* samples_ptr = (int16_t*)ctx->pcm_buf;
|
||||
size_t sample_count = (size_t)samples * info.channels;
|
||||
@@ -206,15 +204,15 @@ static void mp3_playback_task(void* arg) {
|
||||
samples_ptr[i] = (int16_t)scaled;
|
||||
}
|
||||
|
||||
// Play audio via audio-stream (resampled to native 44100)
|
||||
// Play audio to I2S
|
||||
size_t offset = 0;
|
||||
size_t data_size = sample_count * sizeof(int16_t);
|
||||
bool write_err = false;
|
||||
while (offset < data_size && ctx->state == STATE_PLAYING) {
|
||||
size_t written = 0;
|
||||
error_t err = audio_stream_write(ctx->stream_handle, (uint8_t*)ctx->pcm_buf + offset, data_size - offset, &written, pdMS_TO_TICKS(500));
|
||||
error_t err = i2s_controller_write(ctx->i2s_dev, (uint8_t*)ctx->pcm_buf + offset, data_size - offset, &written, pdMS_TO_TICKS(250));
|
||||
if (err != ERROR_NONE || written == 0) {
|
||||
ESP_LOGE(TAG, "Audio stream write failed: %d", err);
|
||||
ESP_LOGE(TAG, "I2S write failed: %d", err);
|
||||
write_err = true;
|
||||
break;
|
||||
}
|
||||
@@ -234,15 +232,18 @@ static void mp3_playback_task(void* arg) {
|
||||
lv_bar_set_value(ctx->bar_progress, pct, LV_ANIM_OFF);
|
||||
tt_lvgl_unlock();
|
||||
}
|
||||
|
||||
// taskYIELD removed to avoid audio gaps - decoder loop is fast enough
|
||||
}
|
||||
|
||||
fclose(ctx->file);
|
||||
ctx->file = NULL;
|
||||
|
||||
// Close audio stream to clean up DMA / resampler task
|
||||
if (ctx->stream_handle != NULL) {
|
||||
audio_stream_close(ctx->stream_handle);
|
||||
ctx->stream_handle = NULL;
|
||||
// Reset I2S controller to clean up DMA channels and stop looping noise
|
||||
if (ctx->i2s_dev) {
|
||||
device_lock(ctx->i2s_dev);
|
||||
i2s_controller_reset(ctx->i2s_dev);
|
||||
device_unlock(ctx->i2s_dev);
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Playback task finished");
|
||||
@@ -291,10 +292,10 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
||||
g_ctx.input_buf = (uint8_t*)malloc(MP3_INPUT_BUFFER_SIZE);
|
||||
g_ctx.pcm_buf = (mp3d_sample_t*)malloc(MINIMP3_MAX_SAMPLES_PER_FRAME * sizeof(mp3d_sample_t));
|
||||
|
||||
// Find audio-stream device (resampling layer over ES8311 codec)
|
||||
g_ctx.stream_dev = device_find_by_name("audio-stream");
|
||||
if (!g_ctx.stream_dev) {
|
||||
ESP_LOGE(TAG, "Audio-stream device not found!");
|
||||
// Find device
|
||||
g_ctx.i2s_dev = device_find_by_name("i2s0");
|
||||
if (!g_ctx.i2s_dev) {
|
||||
ESP_LOGE(TAG, "I2S device 'i2s0' not found!");
|
||||
}
|
||||
|
||||
// Parse launch parameters
|
||||
@@ -392,8 +393,8 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
||||
lv_obj_center(lbl_stop);
|
||||
lv_obj_add_event_cb(g_ctx.btn_stop, on_stop_click, LV_EVENT_CLICKED, &g_ctx);
|
||||
|
||||
if (!g_ctx.stream_dev) {
|
||||
lv_label_set_text(g_ctx.lbl_status, "Error: Audio Not Found");
|
||||
if (!g_ctx.i2s_dev) {
|
||||
lv_label_set_text(g_ctx.lbl_status, "Error: I2S Not Found");
|
||||
lv_obj_add_state(g_ctx.btn_play_pause, LV_STATE_DISABLED);
|
||||
lv_obj_add_state(g_ctx.btn_stop, LV_STATE_DISABLED);
|
||||
} else if (!g_ctx.input_buf || !g_ctx.pcm_buf) {
|
||||
@@ -408,7 +409,7 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
||||
if (g_ctx.filepath[0] != '\0') {
|
||||
g_ctx.state = STATE_PLAYING;
|
||||
update_ui(&g_ctx);
|
||||
xTaskCreate(mp3_playback_task, "mp3_play", 6144, &g_ctx, 6, &g_ctx.playback_task_handle);
|
||||
xTaskCreate(mp3_playback_task, "mp3_play", 4096, &g_ctx, 5, &g_ctx.playback_task_handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -423,10 +424,11 @@ static void onHideApp(AppHandle app, void* data) {
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
}
|
||||
|
||||
// Close any open audio stream
|
||||
if (g_ctx.stream_handle != NULL) {
|
||||
audio_stream_close(g_ctx.stream_handle);
|
||||
g_ctx.stream_handle = NULL;
|
||||
// Reset I2S to ensure DMA channel is stopped
|
||||
if (g_ctx.i2s_dev) {
|
||||
device_lock(g_ctx.i2s_dev);
|
||||
i2s_controller_reset(g_ctx.i2s_dev);
|
||||
device_unlock(g_ctx.i2s_dev);
|
||||
}
|
||||
|
||||
if (g_ctx.input_buf) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.8.0-dev
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32s3
|
||||
[app]
|
||||
id=one.tactility.mp3player
|
||||
|
||||
@@ -10,5 +10,5 @@ idf_component_register(
|
||||
../../../Libraries/TactilityCpp/Include
|
||||
../../../Libraries/GameKit/Include
|
||||
../../../Libraries/SfxEngine/Include
|
||||
REQUIRES TactilitySDK
|
||||
REQUIRES TactilitySDK esp_driver_i2s esp_driver_gpio
|
||||
)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
#include "DungeonModel.h"
|
||||
|
||||
#ifndef LV_ABS
|
||||
#define LV_ABS(x) ((x) > 0 ? (x) : -(x))
|
||||
#endif
|
||||
|
||||
namespace PocketDungeon {
|
||||
|
||||
uint32_t DungeonModel::nextRandom() {
|
||||
@@ -9,75 +11,48 @@ uint32_t DungeonModel::nextRandom() {
|
||||
return (seed >> 16u) & 0x7FFFu;
|
||||
}
|
||||
|
||||
void DungeonModel::reset(uint32_t seedValue, bool tutorials) {
|
||||
void DungeonModel::reset(uint32_t seedValue) {
|
||||
seed = seedValue;
|
||||
state = DungeonState {};
|
||||
state.floor = tutorials ? 0 : 2;
|
||||
state.floor = 1;
|
||||
state.hp = 5;
|
||||
state.gold = 0;
|
||||
state.gameOver = false;
|
||||
generateFloor();
|
||||
}
|
||||
|
||||
void DungeonModel::clearAllTiles() {
|
||||
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y)
|
||||
for (uint8_t x = 0; x < DUNGEON_COLS; ++x)
|
||||
state.tiles[y][x] = Tile::Floor;
|
||||
for (uint8_t x = 0; x < DUNGEON_COLS; ++x) { state.tiles[0][x] = Tile::Wall; state.tiles[DUNGEON_ROWS-1][x] = Tile::Wall; }
|
||||
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y) { state.tiles[y][0] = Tile::Wall; state.tiles[y][DUNGEON_COLS-1] = Tile::Wall; }
|
||||
}
|
||||
|
||||
void DungeonModel::addEnemy(EntityType type, GameKit::GridPos pos, int8_t hp) {
|
||||
if (state.entityCount >= MAX_ENTITIES) return;
|
||||
state.entities[state.entityCount++] = Entity { type, pos, hp, true };
|
||||
}
|
||||
|
||||
void DungeonModel::generateTutorialFloor(uint8_t tId) {
|
||||
clearAllTiles();
|
||||
state.entityCount = 0;
|
||||
state.renderRows = TUTORIAL_ROWS;
|
||||
for (uint8_t y = TUTORIAL_ROWS + 1; y < DUNGEON_ROWS; ++y)
|
||||
for (uint8_t x = 0; x < DUNGEON_COLS; ++x)
|
||||
state.tiles[y][x] = Tile::Wall;
|
||||
state.playerPos = {1, 1};
|
||||
if (tId == 0) {
|
||||
state.tiles[1][DUNGEON_COLS - 2] = Tile::Stairs;
|
||||
} else {
|
||||
state.tiles[1][3] = Tile::Treasure;
|
||||
state.tiles[1][DUNGEON_COLS - 2] = Tile::Stairs;
|
||||
addEnemy(EntityType::Bat, {5, 1}, 1);
|
||||
}
|
||||
}
|
||||
|
||||
void DungeonModel::generateFloor() {
|
||||
if (state.floor < 2) { generateTutorialFloor(static_cast<uint8_t>(state.floor)); return; }
|
||||
state.renderRows = DUNGEON_ROWS;
|
||||
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y)
|
||||
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y) {
|
||||
for (uint8_t x = 0; x < DUNGEON_COLS; ++x) {
|
||||
const bool edge = (x == 0 || y == 0 || x == DUNGEON_COLS - 1 || y == DUNGEON_ROWS - 1);
|
||||
state.tiles[y][x] = edge ? Tile::Wall : Tile::Floor;
|
||||
}
|
||||
}
|
||||
state.playerPos = {1, 1};
|
||||
state.entityCount = 0;
|
||||
state.tiles[2][4] = Tile::Wall;
|
||||
state.tiles[3][4] = Tile::Wall;
|
||||
state.tiles[5][2 + (state.floor % 2)] = Tile::Treasure;
|
||||
state.tiles[5][7] = Tile::Stairs;
|
||||
if (state.floor > 2) {
|
||||
if (state.floor > 1) {
|
||||
auto x = static_cast<uint8_t>(2 + (nextRandom() % 5));
|
||||
auto y = static_cast<uint8_t>(1 + (nextRandom() % 5));
|
||||
if (!(x == 1 && y == 1) && !(x == 7 && y == 5)) state.tiles[y][x] = Tile::Wall;
|
||||
}
|
||||
uint16_t realFloor = state.floor - 1;
|
||||
addEnemy(EntityType::Slime, {5, 2}, 1 + static_cast<int8_t>(realFloor / 3));
|
||||
addEnemy(EntityType::Slime, {5, 2}, 1 + static_cast<int8_t>(state.floor / 3));
|
||||
addEnemy(EntityType::Bat, {3, 5}, 1);
|
||||
if (realFloor >= 3) addEnemy(EntityType::Slime, {7, 4}, 2);
|
||||
if (state.floor >= 3) addEnemy(EntityType::Slime, {7, 4}, 2);
|
||||
}
|
||||
|
||||
int DungeonModel::findEnemyAt(GameKit::GridPos pos) const {
|
||||
for (uint8_t i = 0; i < state.entityCount; ++i) {
|
||||
const auto& e = state.entities[i];
|
||||
if (e.alive && e.pos == pos) return i;
|
||||
const auto& entity = state.entities[i];
|
||||
if (entity.alive && entity.pos == pos) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
@@ -95,8 +70,11 @@ MoveResult DungeonModel::movePlayer(int dx, int dy) {
|
||||
if (enemyIdx >= 0) {
|
||||
Entity& enemy = state.entities[enemyIdx];
|
||||
enemy.hp -= 1;
|
||||
if (enemy.hp <= 0) { enemy.alive = false; state.gold += enemy.type == EntityType::Bat ? 2 : 1; }
|
||||
if (!isTutorial()) moveMonsters();
|
||||
if (enemy.hp <= 0) {
|
||||
enemy.alive = false;
|
||||
state.gold += enemy.type == EntityType::Bat ? 2 : 1;
|
||||
}
|
||||
moveMonsters();
|
||||
if (state.hp <= 0) { state.gameOver = true; return MoveResult::Dead; }
|
||||
return MoveResult::Attacked;
|
||||
}
|
||||
@@ -109,19 +87,15 @@ MoveResult DungeonModel::movePlayer(int dx, int dy) {
|
||||
result = MoveResult::Treasure;
|
||||
} else if (tile == Tile::Stairs) {
|
||||
state.floor += 1;
|
||||
if (state.floor >= 2) {
|
||||
if (state.floor - 1 > bestFloor) bestFloor = state.floor - 1;
|
||||
if (state.gold > bestGold) bestGold = state.gold;
|
||||
}
|
||||
if (state.floor > bestFloor) bestFloor = state.floor;
|
||||
if (state.gold > bestGold) bestGold = state.gold;
|
||||
generateFloor();
|
||||
return MoveResult::Stairs;
|
||||
}
|
||||
if (!isTutorial()) moveMonsters();
|
||||
moveMonsters();
|
||||
if (state.hp <= 0) { state.gameOver = true; return MoveResult::Dead; }
|
||||
if (state.floor >= 2) {
|
||||
if (state.floor - 1 > bestFloor) bestFloor = state.floor - 1;
|
||||
if (state.gold > bestGold) bestGold = state.gold;
|
||||
}
|
||||
if (state.floor > bestFloor) bestFloor = state.floor;
|
||||
if (state.gold > bestGold) bestGold = state.gold;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -131,19 +105,26 @@ void DungeonModel::moveMonsters() {
|
||||
if (!enemy.alive) continue;
|
||||
const int16_t dx = state.playerPos.x - enemy.pos.x;
|
||||
const int16_t dy = state.playerPos.y - enemy.pos.y;
|
||||
if ((dx == 0 && (dy == 1 || dy == -1)) || (dy == 0 && (dx == 1 || dx == -1))) { state.hp -= 1; continue; }
|
||||
if ((dx == 0 && (dy == 1 || dy == -1)) || (dy == 0 && (dx == 1 || dx == -1))) {
|
||||
state.hp -= 1;
|
||||
continue;
|
||||
}
|
||||
GameKit::GridPos target = enemy.pos;
|
||||
if (enemy.type == EntityType::Bat && (nextRandom() % 3 == 0)) {
|
||||
const int dirs[4][2] = {{1,0},{-1,0},{0,1},{0,-1}};
|
||||
const int* dir = dirs[nextRandom() % 4];
|
||||
target.x += dir[0]; target.y += dir[1];
|
||||
target.x += dir[0];
|
||||
target.y += dir[1];
|
||||
} else if (LV_ABS(dx) > LV_ABS(dy)) {
|
||||
target.x += dx > 0 ? 1 : -1;
|
||||
} else if (dy != 0) {
|
||||
target.y += dy > 0 ? 1 : -1;
|
||||
}
|
||||
if (target == state.playerPos) state.hp -= 1;
|
||||
else if (isWalkable(target) && findEnemyAt(target) < 0) enemy.pos = target;
|
||||
if (target == state.playerPos) {
|
||||
state.hp -= 1;
|
||||
} else if (isWalkable(target) && findEnemyAt(target) < 0) {
|
||||
enemy.pos = target;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
#include <cstdint>
|
||||
|
||||
namespace PocketDungeon {
|
||||
|
||||
static constexpr uint8_t DUNGEON_COLS = 9;
|
||||
static constexpr uint8_t DUNGEON_ROWS = 7;
|
||||
static constexpr uint8_t TUTORIAL_ROWS = 2;
|
||||
static constexpr uint8_t MAX_ENTITIES = 8;
|
||||
|
||||
enum class Tile : uint8_t { Floor, Wall, Stairs, Treasure };
|
||||
@@ -25,11 +25,10 @@ struct DungeonState {
|
||||
Entity entities[MAX_ENTITIES] {};
|
||||
uint8_t entityCount = 0;
|
||||
GameKit::GridPos playerPos {1, 1};
|
||||
uint16_t floor = 0; // 0,1 tutorial, 2+ real
|
||||
uint16_t floor = 1;
|
||||
uint16_t gold = 0;
|
||||
int8_t hp = 5;
|
||||
bool gameOver = false;
|
||||
uint8_t renderRows = DUNGEON_ROWS;
|
||||
};
|
||||
|
||||
class DungeonModel {
|
||||
@@ -43,11 +42,9 @@ class DungeonModel {
|
||||
int findEnemyAt(GameKit::GridPos pos) const;
|
||||
bool isWalkable(GameKit::GridPos pos) const;
|
||||
void moveMonsters();
|
||||
void generateTutorialFloor(uint8_t tId);
|
||||
void clearAllTiles();
|
||||
|
||||
public:
|
||||
void reset(uint32_t seedValue = 0xC0FFEE, bool tutorials = true);
|
||||
void reset(uint32_t seedValue = 0xC0FFEE);
|
||||
void generateFloor();
|
||||
MoveResult movePlayer(int dx, int dy);
|
||||
|
||||
@@ -55,9 +52,6 @@ public:
|
||||
uint16_t getBestFloor() const { return bestFloor; }
|
||||
uint16_t getBestGold() const { return bestGold; }
|
||||
void setBests(uint16_t floor, uint16_t gold) { bestFloor = floor; bestGold = gold; }
|
||||
bool isTutorial() const { return state.floor < 2; }
|
||||
uint8_t tutorialId() const { return static_cast<uint8_t>(state.floor); }
|
||||
uint16_t displayFloor() const { return state.floor < 2 ? 1 : state.floor - 1; }
|
||||
};
|
||||
|
||||
} // namespace PocketDungeon
|
||||
|
||||
@@ -19,7 +19,7 @@ void DungeonRenderer::create(lv_obj_t* parent) {
|
||||
lv_obj_remove_flag(root, LV_OBJ_FLAG_SCROLLABLE);
|
||||
status = lv_label_create(root);
|
||||
lv_obj_set_style_text_color(status, lv_color_hex(0xF2E8D0), LV_PART_MAIN);
|
||||
lv_obj_align(status, LV_ALIGN_TOP_MID, 0, 6);
|
||||
lv_obj_align(status, LV_ALIGN_TOP_MID, 0, 8);
|
||||
const lv_coord_t screenW = lv_display_get_horizontal_resolution(nullptr);
|
||||
const lv_coord_t screenH = lv_display_get_vertical_resolution(nullptr);
|
||||
const uint16_t maxCellW = static_cast<uint16_t>((screenW - 22) / DUNGEON_COLS);
|
||||
@@ -48,7 +48,8 @@ lv_color_t DungeonRenderer::tileColor(Tile tile) {
|
||||
case Tile::Wall: return lv_color_hex(COLOR_WALL);
|
||||
case Tile::Stairs: return lv_color_hex(COLOR_STAIRS);
|
||||
case Tile::Treasure: return lv_color_hex(COLOR_TREASURE);
|
||||
case Tile::Floor: default: return lv_color_hex(COLOR_FLOOR);
|
||||
case Tile::Floor:
|
||||
default: return lv_color_hex(COLOR_FLOOR);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,64 +65,48 @@ lv_color_t DungeonRenderer::entityColor(EntityType type) {
|
||||
switch (type) {
|
||||
case EntityType::Player: return lv_color_hex(COLOR_PLAYER);
|
||||
case EntityType::Bat: return lv_color_hex(COLOR_BAT);
|
||||
case EntityType::Slime: default: return lv_color_hex(COLOR_SLIME);
|
||||
case EntityType::Slime:
|
||||
default: return lv_color_hex(COLOR_SLIME);
|
||||
}
|
||||
}
|
||||
|
||||
void DungeonRenderer::clearEntityLabels() {
|
||||
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y)
|
||||
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y) {
|
||||
for (uint8_t x = 0; x < DUNGEON_COLS; ++x) {
|
||||
lv_label_set_text(labels[y][x], "");
|
||||
lv_obj_set_style_text_color(labels[y][x], lv_color_white(), LV_PART_MAIN);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void DungeonRenderer::render(const DungeonModel& model, MoveResult lastResult) {
|
||||
const DungeonState& state = model.getState();
|
||||
char buf[96];
|
||||
if (model.isTutorial()) {
|
||||
std::snprintf(buf, sizeof(buf), "TUTORIAL %u/2 HP:%d Gold:%u", model.tutorialId()+1, state.hp, state.gold);
|
||||
} else {
|
||||
std::snprintf(buf, sizeof(buf), "F%u HP:%d Gold:%u Best:%u/%u", model.displayFloor(), state.hp, state.gold, model.getBestFloor(), model.getBestGold());
|
||||
}
|
||||
std::snprintf(buf, sizeof(buf), "F%u HP:%d Gold:%u Best:%u/%u", state.floor, state.hp, state.gold, model.getBestFloor(), model.getBestGold());
|
||||
lv_label_set_text(status, buf);
|
||||
clearEntityLabels();
|
||||
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y) {
|
||||
bool visible = y < state.renderRows;
|
||||
if (!visible) {
|
||||
for (uint8_t x = 0; x < DUNGEON_COLS; ++x) lv_obj_add_flag(cells[y][x], LV_OBJ_FLAG_HIDDEN);
|
||||
continue;
|
||||
}
|
||||
for (uint8_t x = 0; x < DUNGEON_COLS; ++x) {
|
||||
lv_obj_clear_flag(cells[y][x], LV_OBJ_FLAG_HIDDEN);
|
||||
const Tile tile = state.tiles[y][x];
|
||||
lv_obj_set_style_bg_color(cells[y][x], tileColor(tile), LV_PART_MAIN);
|
||||
lv_label_set_text(labels[y][x], tileText(tile));
|
||||
}
|
||||
}
|
||||
if (state.renderRows == TUTORIAL_ROWS) {
|
||||
lv_obj_set_height(board, grid.cellPx * TUTORIAL_ROWS);
|
||||
lv_obj_align(board, LV_ALIGN_CENTER, 0, -10);
|
||||
} else {
|
||||
lv_obj_set_height(board, grid.rows * grid.cellPx);
|
||||
lv_obj_align(board, LV_ALIGN_CENTER, 0, 8);
|
||||
}
|
||||
for (uint8_t i = 0; i < state.entityCount; ++i) {
|
||||
const Entity& e = state.entities[i];
|
||||
if (!e.alive) continue;
|
||||
const char* sym = e.type == EntityType::Bat ? "b" : "s";
|
||||
lv_label_set_text(labels[e.pos.y][e.pos.x], sym);
|
||||
lv_obj_set_style_text_color(labels[e.pos.y][e.pos.x], entityColor(e.type), LV_PART_MAIN);
|
||||
const Entity& entity = state.entities[i];
|
||||
if (!entity.alive) continue;
|
||||
const char* symbol = entity.type == EntityType::Bat ? "b" : "s";
|
||||
lv_label_set_text(labels[entity.pos.y][entity.pos.x], symbol);
|
||||
lv_obj_set_style_text_color(labels[entity.pos.y][entity.pos.x], entityColor(entity.type), LV_PART_MAIN);
|
||||
}
|
||||
lv_label_set_text(labels[state.playerPos.y][state.playerPos.x], "@");
|
||||
lv_obj_set_style_text_color(labels[state.playerPos.y][state.playerPos.x], lv_color_hex(COLOR_PLAYER), LV_PART_MAIN);
|
||||
|
||||
const char* text = model.isTutorial() ? (model.tutorialId()==0 ? "TUT1: @ to >" : "TUT2: Kill b, $ , >") : "Arrows/Swipe/Tap";
|
||||
const char* text = "Move with arrows, swipe, or tap a direction";
|
||||
switch (lastResult) {
|
||||
case MoveResult::Blocked: text = "Wall blocks"; break;
|
||||
case MoveResult::Attacked: text = "You strike!"; break;
|
||||
case MoveResult::Treasure: text = "Treasure! +5"; break;
|
||||
case MoveResult::Stairs: text = model.isTutorial() ? "Tutorial done!" : "Down..."; break;
|
||||
case MoveResult::Blocked: text = "A wall blocks the way"; break;
|
||||
case MoveResult::Attacked: text = "You strike the monster"; break;
|
||||
case MoveResult::Treasure: text = "Treasure found!"; break;
|
||||
case MoveResult::Stairs: text = "Down the stairs..."; break;
|
||||
case MoveResult::Dead: text = "Game over - tap to restart"; break;
|
||||
default: break;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
#include "PocketDungeon.h"
|
||||
#include <cstdio>
|
||||
extern "C" {
|
||||
#include <tt_app.h>
|
||||
}
|
||||
#include <tt_lvgl_toolbar.h>
|
||||
|
||||
namespace PocketDungeon {
|
||||
|
||||
@@ -10,26 +7,19 @@ static constexpr uint32_t TICK_MS = 200;
|
||||
static constexpr const char* PREF_BEST_FLOOR = "bestFloor";
|
||||
static constexpr const char* PREF_BEST_GOLD = "bestGold";
|
||||
|
||||
static constexpr uint32_t COLOR_BG = 0x10111A;
|
||||
static constexpr uint32_t COLOR_PANEL = 0x1D2030;
|
||||
static constexpr uint32_t COLOR_ACCENT = 0x365CF5;
|
||||
static constexpr uint32_t COLOR_OVERLAY = 0x000000;
|
||||
|
||||
void PocketDungeon::onShow(AppHandle app, lv_obj_t* parent) {
|
||||
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_set_style_pad_all(parent, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_color(parent, lv_color_hex(COLOR_BG), LV_PART_MAIN);
|
||||
root = parent;
|
||||
(void) tt_lvgl_toolbar_create_for_app(parent, app);
|
||||
model.setBests(static_cast<uint16_t>(prefs.getInt(PREF_BEST_FLOOR, 1)), static_cast<uint16_t>(prefs.getInt(PREF_BEST_GOLD, 0)));
|
||||
model.reset(lv_tick_get(), true);
|
||||
model.reset(lv_tick_get());
|
||||
if (sfx == nullptr) {
|
||||
sfx = new SfxEngine();
|
||||
if (sfx->start()) sfx->applyVolumePreset(SfxEngine::VolumePreset::Quiet);
|
||||
}
|
||||
phase = AppPhase::Intro;
|
||||
onEnter(parent);
|
||||
loop.start(TICK_MS, this);
|
||||
(void) app;
|
||||
}
|
||||
|
||||
void PocketDungeon::onHide(AppHandle app) {
|
||||
@@ -37,300 +27,63 @@ void PocketDungeon::onHide(AppHandle app) {
|
||||
loop.stop();
|
||||
onExit();
|
||||
saveBests();
|
||||
if (sfx) { sfx->stop(); delete sfx; sfx = nullptr; }
|
||||
if (sfx != nullptr) {
|
||||
sfx->stop();
|
||||
delete sfx;
|
||||
sfx = nullptr;
|
||||
}
|
||||
root = nullptr;
|
||||
}
|
||||
|
||||
void PocketDungeon::onEnter(lv_obj_t* sceneRoot) {
|
||||
root = sceneRoot;
|
||||
GameKit::attachInput(root, this);
|
||||
showIntro();
|
||||
renderer.create(sceneRoot);
|
||||
GameKit::attachInput(sceneRoot, this);
|
||||
lastResult = MoveResult::None;
|
||||
}
|
||||
|
||||
void PocketDungeon::onExit() { clearIntro(); clearGame(); }
|
||||
|
||||
void PocketDungeon::attachInputToCurrent() {
|
||||
if (root) GameKit::attachInput(root, this);
|
||||
if (introContainer) {
|
||||
lv_obj_add_flag(introContainer, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_add_flag(introContainer, LV_OBJ_FLAG_GESTURE_BUBBLE);
|
||||
GameKit::attachInput(introContainer, this);
|
||||
}
|
||||
if (gameContainer) {
|
||||
lv_obj_add_flag(gameContainer, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_add_flag(gameContainer, LV_OBJ_FLAG_GESTURE_BUBBLE);
|
||||
GameKit::attachInput(gameContainer, this);
|
||||
lv_obj_add_event_cb(gameContainer, onGameContainerLongPress, LV_EVENT_LONG_PRESSED, this);
|
||||
}
|
||||
if (pauseOverlay) {
|
||||
lv_obj_add_flag(pauseOverlay, LV_OBJ_FLAG_CLICKABLE);
|
||||
GameKit::attachInput(pauseOverlay, this);
|
||||
}
|
||||
}
|
||||
|
||||
void PocketDungeon::clearIntro() { if (introContainer) { lv_obj_delete(introContainer); introContainer = nullptr; } }
|
||||
void PocketDungeon::clearGame() {
|
||||
if (pauseOverlay) { lv_obj_delete(pauseOverlay); pauseOverlay = nullptr; }
|
||||
if (gameContainer) { lv_obj_delete(gameContainer); gameContainer = nullptr; }
|
||||
}
|
||||
|
||||
void PocketDungeon::showIntro() {
|
||||
clearGame(); clearIntro();
|
||||
phase = AppPhase::Intro;
|
||||
introContainer = lv_obj_create(root);
|
||||
lv_obj_set_size(introContainer, LV_PCT(100), LV_PCT(100));
|
||||
lv_obj_set_style_pad_all(introContainer, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(introContainer, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_color(introContainer, lv_color_hex(COLOR_BG), LV_PART_MAIN);
|
||||
lv_obj_remove_flag(introContainer, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_set_flex_flow(introContainer, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(introContainer, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_row(introContainer, 6, LV_PART_MAIN);
|
||||
lv_obj_add_flag(introContainer, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_add_flag(introContainer, LV_OBJ_FLAG_GESTURE_BUBBLE);
|
||||
renderIntro();
|
||||
attachInputToCurrent();
|
||||
}
|
||||
|
||||
void PocketDungeon::showGame() {
|
||||
clearIntro(); clearGame();
|
||||
phase = AppPhase::Playing;
|
||||
gameContainer = lv_obj_create(root);
|
||||
lv_obj_set_size(gameContainer, LV_PCT(100), LV_PCT(100));
|
||||
lv_obj_set_style_pad_all(gameContainer, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(gameContainer, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_opa(gameContainer, LV_OPA_TRANSP, LV_PART_MAIN);
|
||||
lv_obj_remove_flag(gameContainer, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_add_flag(gameContainer, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_add_flag(gameContainer, LV_OBJ_FLAG_GESTURE_BUBBLE);
|
||||
renderer.create(gameContainer);
|
||||
lv_obj_t* pauseBtn = lv_btn_create(gameContainer);
|
||||
lv_obj_set_size(pauseBtn, 32, 26);
|
||||
lv_obj_set_style_bg_color(pauseBtn, lv_color_hex(0x2A2D40), LV_PART_MAIN);
|
||||
lv_obj_set_style_radius(pauseBtn, 6, LV_PART_MAIN);
|
||||
lv_obj_set_style_pad_all(pauseBtn, 0, LV_PART_MAIN);
|
||||
lv_obj_align(pauseBtn, LV_ALIGN_TOP_RIGHT, -6, 4);
|
||||
lv_obj_add_event_cb(pauseBtn, onExitButtonClicked, LV_EVENT_CLICKED, this);
|
||||
lv_obj_t* pauseLbl = lv_label_create(pauseBtn);
|
||||
lv_label_set_text(pauseLbl, LV_SYMBOL_CLOSE);
|
||||
lv_obj_center(pauseLbl);
|
||||
lastResult = MoveResult::None;
|
||||
attachInputToCurrent();
|
||||
render();
|
||||
if (sfx) sfx->play(SfxId::Warp);
|
||||
}
|
||||
|
||||
void PocketDungeon::showPause() {
|
||||
if (pauseOverlay) return;
|
||||
phase = AppPhase::Paused;
|
||||
pauseOverlay = lv_obj_create(root);
|
||||
lv_obj_set_size(pauseOverlay, LV_PCT(100), LV_PCT(100));
|
||||
lv_obj_set_style_pad_all(pauseOverlay, 12, LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(pauseOverlay, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_color(pauseOverlay, lv_color_hex(COLOR_OVERLAY), LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_opa(pauseOverlay, LV_OPA_70, LV_PART_MAIN);
|
||||
lv_obj_remove_flag(pauseOverlay, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_set_flex_flow(pauseOverlay, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(pauseOverlay, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_row(pauseOverlay, 10, LV_PART_MAIN);
|
||||
lv_obj_add_flag(pauseOverlay, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_t* box = lv_obj_create(pauseOverlay);
|
||||
lv_obj_set_width(box, LV_PCT(80));
|
||||
lv_obj_set_height(box, LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(box, 14, LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_color(box, lv_color_hex(COLOR_PANEL), LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(box, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_radius(box, 12, LV_PART_MAIN);
|
||||
lv_obj_set_flex_flow(box, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(box, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_row(box, 10, LV_PART_MAIN);
|
||||
lv_obj_remove_flag(box, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_t* t = lv_label_create(box);
|
||||
lv_label_set_text(t, "Paused");
|
||||
lv_obj_set_style_text_color(t, lv_color_white(), LV_PART_MAIN);
|
||||
lv_obj_t* resumeBtn = lv_btn_create(box);
|
||||
lv_obj_set_width(resumeBtn, LV_PCT(100));
|
||||
lv_obj_set_height(resumeBtn, 38);
|
||||
lv_obj_set_style_bg_color(resumeBtn, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN);
|
||||
lv_obj_set_style_radius(resumeBtn, 10, LV_PART_MAIN);
|
||||
lv_obj_add_event_cb(resumeBtn, onResumeButtonClicked, LV_EVENT_CLICKED, this);
|
||||
lv_obj_t* rl = lv_label_create(resumeBtn);
|
||||
lv_label_set_text(rl, "Resume");
|
||||
lv_obj_center(rl);
|
||||
lv_obj_t* exitBtn = lv_btn_create(box);
|
||||
lv_obj_set_width(exitBtn, LV_PCT(100));
|
||||
lv_obj_set_height(exitBtn, 36);
|
||||
lv_obj_set_style_bg_color(exitBtn, lv_color_hex(0x2E303F), LV_PART_MAIN);
|
||||
lv_obj_set_style_radius(exitBtn, 10, LV_PART_MAIN);
|
||||
lv_obj_add_event_cb(exitBtn, onExitButtonClicked, LV_EVENT_CLICKED, this);
|
||||
lv_obj_t* el = lv_label_create(exitBtn);
|
||||
lv_label_set_text(el, "Exit Game");
|
||||
lv_obj_center(el);
|
||||
if (sfx) sfx->play(SfxId::MenuOpen);
|
||||
attachInputToCurrent();
|
||||
}
|
||||
|
||||
void PocketDungeon::hidePause() {
|
||||
if (pauseOverlay) { lv_obj_delete(pauseOverlay); pauseOverlay = nullptr; }
|
||||
phase = AppPhase::Playing;
|
||||
if (sfx) sfx->play(SfxId::MenuClose);
|
||||
attachInputToCurrent();
|
||||
render();
|
||||
}
|
||||
|
||||
void PocketDungeon::exitGame() {
|
||||
saveBests();
|
||||
if (sfx) sfx->play(SfxId::Cancel);
|
||||
tt_app_stop();
|
||||
}
|
||||
|
||||
void PocketDungeon::renderIntro() {
|
||||
if (!introContainer) return;
|
||||
lv_obj_t* titleRow = lv_obj_create(introContainer);
|
||||
lv_obj_set_width(titleRow, LV_PCT(100));
|
||||
lv_obj_set_height(titleRow, LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(titleRow, 6, LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(titleRow, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_opa(titleRow, LV_OPA_TRANSP, LV_PART_MAIN);
|
||||
lv_obj_remove_flag(titleRow, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_set_flex_flow(titleRow, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(titleRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_row(titleRow, 2, LV_PART_MAIN);
|
||||
lv_obj_t* title = lv_label_create(titleRow);
|
||||
lv_label_set_text(title, "POCKET DUNGEON");
|
||||
lv_obj_set_style_text_color(title, lv_color_hex(0xF8F3E6), LV_PART_MAIN);
|
||||
lv_obj_t* subtitle = lv_label_create(titleRow);
|
||||
lv_label_set_text(subtitle, "Tiny paper roguelike");
|
||||
lv_obj_set_style_text_color(subtitle, lv_color_hex(0x8A9FBF), LV_PART_MAIN);
|
||||
char bestBuf[64];
|
||||
std::snprintf(bestBuf, sizeof(bestBuf), "Best F%u G%u", model.getBestFloor(), model.getBestGold());
|
||||
lv_obj_t* best = lv_label_create(titleRow);
|
||||
lv_label_set_text(best, bestBuf);
|
||||
lv_obj_set_style_text_color(best, lv_color_hex(0x7A8196), LV_PART_MAIN);
|
||||
|
||||
lv_obj_t* mainRow = lv_obj_create(introContainer);
|
||||
lv_obj_set_width(mainRow, LV_PCT(100));
|
||||
lv_obj_set_flex_grow(mainRow, 1);
|
||||
lv_obj_set_style_pad_all(mainRow, 4, LV_PART_MAIN);
|
||||
lv_obj_set_style_pad_column(mainRow, 8, LV_PART_MAIN);
|
||||
lv_obj_set_style_pad_bottom(mainRow, 8, LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(mainRow, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_opa(mainRow, LV_OPA_TRANSP, LV_PART_MAIN);
|
||||
lv_obj_set_flex_flow(mainRow, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_flex_align(mainRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
|
||||
lv_obj_remove_flag(mainRow, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
lv_obj_t* leftCol = lv_obj_create(mainRow);
|
||||
lv_obj_set_flex_grow(leftCol, 1);
|
||||
lv_obj_set_height(leftCol, LV_PCT(100));
|
||||
lv_obj_set_style_pad_all(leftCol, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_pad_right(leftCol, 4, LV_PART_MAIN);
|
||||
lv_obj_set_style_pad_bottom(leftCol, 6, LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(leftCol, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_opa(leftCol, LV_OPA_TRANSP, LV_PART_MAIN);
|
||||
lv_obj_set_flex_flow(leftCol, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_style_pad_row(leftCol, 8, LV_PART_MAIN);
|
||||
lv_obj_add_flag(leftCol, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
auto makeCard = [](lv_obj_t* parent, const char* tTitle, const char* body, uint32_t col) {
|
||||
lv_obj_t* card = lv_obj_create(parent);
|
||||
lv_obj_set_width(card, LV_PCT(100));
|
||||
lv_obj_set_height(card, LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(card, 9, LV_PART_MAIN);
|
||||
lv_obj_set_style_pad_row(card, 3, LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(card, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_radius(card, 10, LV_PART_MAIN);
|
||||
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_t* t = lv_label_create(card);
|
||||
lv_label_set_text(t, tTitle);
|
||||
lv_obj_set_style_text_color(t, lv_color_hex(col), LV_PART_MAIN);
|
||||
lv_obj_t* b = lv_label_create(card);
|
||||
lv_label_set_text(b, body);
|
||||
lv_obj_set_style_text_color(b, lv_color_hex(0xD9DDE8), LV_PART_MAIN);
|
||||
lv_label_set_long_mode(b, LV_LABEL_LONG_WRAP);
|
||||
lv_obj_set_width(b, LV_PCT(100));
|
||||
return card;
|
||||
};
|
||||
|
||||
makeCard(leftCol, "GOAL", "Reach deepest floor.\nCollect gold, survive.", 0xD99B23);
|
||||
makeCard(leftCol, "CONTROLS", "Arrows / WASD / Swipe\nTap quadrant = move\nQ / ESC = exit", 0x6D8CFF);
|
||||
|
||||
lv_obj_t* rightRail = lv_obj_create(mainRow);
|
||||
lv_obj_set_width(rightRail, 44);
|
||||
lv_obj_set_height(rightRail, LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_all(rightRail, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_pad_row(rightRail, 10, LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(rightRail, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_opa(rightRail, LV_OPA_TRANSP, LV_PART_MAIN);
|
||||
lv_obj_set_flex_flow(rightRail, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(rightRail, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_remove_flag(rightRail, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
auto makeIconBtn = [](lv_obj_t* parent, const char* symbol, uint32_t bg, lv_event_cb_t cb, void* user, int s) -> lv_obj_t* {
|
||||
lv_obj_t* btn = lv_btn_create(parent);
|
||||
lv_obj_set_size(btn, s, s);
|
||||
lv_obj_set_style_bg_color(btn, lv_color_hex(bg), LV_PART_MAIN);
|
||||
lv_obj_set_style_radius(btn, s / 3, LV_PART_MAIN);
|
||||
lv_obj_set_style_pad_all(btn, 0, LV_PART_MAIN);
|
||||
lv_obj_add_event_cb(btn, cb, LV_EVENT_CLICKED, user);
|
||||
lv_obj_t* lbl = lv_label_create(btn);
|
||||
lv_label_set_text(lbl, symbol);
|
||||
lv_obj_center(lbl);
|
||||
lv_obj_set_style_text_color(lbl, lv_color_white(), LV_PART_MAIN);
|
||||
return btn;
|
||||
};
|
||||
|
||||
makeIconBtn(rightRail, LV_SYMBOL_PLAY, COLOR_ACCENT, onStartButtonClicked, this, 38);
|
||||
makeIconBtn(rightRail, LV_SYMBOL_CLOSE, 0x252836, onExitButtonClicked, this, 30);
|
||||
}
|
||||
|
||||
void PocketDungeon::onStartButtonClicked(lv_event_t* e) { auto* s = static_cast<PocketDungeon*>(lv_event_get_user_data(e)); if (s) s->showGame(); lv_event_stop_bubbling(e); }
|
||||
void PocketDungeon::onResumeButtonClicked(lv_event_t* e) { auto* s = static_cast<PocketDungeon*>(lv_event_get_user_data(e)); if (s) s->hidePause(); lv_event_stop_bubbling(e); }
|
||||
void PocketDungeon::onExitButtonClicked(lv_event_t* e) { auto* s = static_cast<PocketDungeon*>(lv_event_get_user_data(e)); if (s) s->exitGame(); lv_event_stop_bubbling(e); }
|
||||
void PocketDungeon::onGameContainerLongPress(lv_event_t* e) { auto* s = static_cast<PocketDungeon*>(lv_event_get_user_data(e)); if (s && s->phase==AppPhase::Playing) s->showPause(); }
|
||||
void PocketDungeon::onExit() {}
|
||||
|
||||
void PocketDungeon::onInput(const GameKit::InputEvent& input) {
|
||||
if (input.kind == GameKit::InputKind::Cancel || input.kind == GameKit::InputKind::Menu) { exitGame(); return; }
|
||||
if (phase == AppPhase::Intro) { if (input.kind != GameKit::InputKind::Unknown) showGame(); return; }
|
||||
if (phase == AppPhase::Paused) { if (input.kind != GameKit::InputKind::Unknown) hidePause(); return; }
|
||||
int dx=0, dy=0;
|
||||
int dx = 0;
|
||||
int dy = 0;
|
||||
switch (input.kind) {
|
||||
case GameKit::InputKind::Up: dy=-1; break;
|
||||
case GameKit::InputKind::Down: dy=1; break;
|
||||
case GameKit::InputKind::Left: dx=-1; break;
|
||||
case GameKit::InputKind::Right: dx=1; break;
|
||||
case GameKit::InputKind::Up: dy = -1; break;
|
||||
case GameKit::InputKind::Down: dy = 1; break;
|
||||
case GameKit::InputKind::Left: dx = -1; break;
|
||||
case GameKit::InputKind::Right: dx = 1; break;
|
||||
default: break;
|
||||
}
|
||||
if (model.getState().gameOver) {
|
||||
if (dx!=0||dy!=0||input.kind==GameKit::InputKind::Confirm||input.kind==GameKit::InputKind::Touch) {
|
||||
model.reset(lv_tick_get(), true); lastResult=MoveResult::None; render();
|
||||
}
|
||||
model.reset(lv_tick_get());
|
||||
lastResult = MoveResult::None;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (dx!=0||dy!=0) {
|
||||
lastResult=model.movePlayer(dx,dy);
|
||||
if (dx != 0 || dy != 0) {
|
||||
lastResult = model.movePlayer(dx, dy);
|
||||
playResultSound(lastResult);
|
||||
if (lastResult==MoveResult::Dead) saveBests();
|
||||
if (lastResult == MoveResult::Dead) saveBests();
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
void PocketDungeon::onTick(const GameKit::Tick& tick){ (void)tick; }
|
||||
void PocketDungeon::render(){
|
||||
if (phase==AppPhase::Intro||phase==AppPhase::Paused) return;
|
||||
if (!gameContainer) return;
|
||||
void PocketDungeon::onTick(const GameKit::Tick& tick) {
|
||||
(void) tick;
|
||||
}
|
||||
|
||||
void PocketDungeon::render() {
|
||||
renderer.render(model, lastResult);
|
||||
}
|
||||
void PocketDungeon::saveBests(){
|
||||
|
||||
void PocketDungeon::saveBests() {
|
||||
prefs.putInt(PREF_BEST_FLOOR, model.getBestFloor());
|
||||
prefs.putInt(PREF_BEST_GOLD, model.getBestGold());
|
||||
}
|
||||
void PocketDungeon::playResultSound(MoveResult r){
|
||||
if (!sfx) return;
|
||||
switch(r){
|
||||
|
||||
void PocketDungeon::playResultSound(MoveResult result) {
|
||||
if (sfx == nullptr) return;
|
||||
switch (result) {
|
||||
case MoveResult::Attacked: sfx->play(SfxId::Hurt); break;
|
||||
case MoveResult::Treasure: sfx->play(SfxId::Coin); break;
|
||||
case MoveResult::Stairs: sfx->play(SfxId::Warp); break;
|
||||
|
||||
@@ -9,15 +9,8 @@
|
||||
|
||||
namespace PocketDungeon {
|
||||
|
||||
enum class AppPhase : uint8_t { Intro, Playing, Paused };
|
||||
|
||||
class PocketDungeon final : public App, public GameKit::Scene {
|
||||
lv_obj_t* root = nullptr;
|
||||
lv_obj_t* introContainer = nullptr;
|
||||
lv_obj_t* gameContainer = nullptr;
|
||||
lv_obj_t* pauseOverlay = nullptr;
|
||||
AppPhase phase = AppPhase::Intro;
|
||||
|
||||
GameKit::Loop loop;
|
||||
GameKit::Prefs prefs {"PocketDungeon"};
|
||||
DungeonModel model;
|
||||
@@ -27,20 +20,6 @@ class PocketDungeon final : public App, public GameKit::Scene {
|
||||
|
||||
void playResultSound(MoveResult result);
|
||||
void saveBests();
|
||||
void showIntro();
|
||||
void showGame();
|
||||
void showPause();
|
||||
void hidePause();
|
||||
void clearIntro();
|
||||
void clearGame();
|
||||
void renderIntro();
|
||||
void attachInputToCurrent();
|
||||
void exitGame();
|
||||
|
||||
static void onStartButtonClicked(lv_event_t* e);
|
||||
static void onResumeButtonClicked(lv_event_t* e);
|
||||
static void onExitButtonClicked(lv_event_t* e);
|
||||
static void onGameContainerLongPress(lv_event_t* e);
|
||||
|
||||
public:
|
||||
void onShow(AppHandle app, lv_obj_t* parent) override;
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.8.0-dev
|
||||
platforms=esp32s3,esp32p4
|
||||
sdk=0.7.0-dev
|
||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||
[app]
|
||||
id=one.tactility.pocketdungeon
|
||||
versionName=0.1.2
|
||||
versionCode=3
|
||||
versionName=0.1.0
|
||||
versionCode=1
|
||||
name=Pocket Dungeon
|
||||
description=Tiny paper-inspired dungeon crawler - tutorial + fullscreen
|
||||
flags=HideStatusBar
|
||||
description=Tiny paper-inspired dungeon crawler
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include <tt_app.h>
|
||||
#include <tt_lvgl_toolbar.h>
|
||||
#include <tactility/lvgl_fonts.h>
|
||||
#include <tt_mdns.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
@@ -8,10 +9,8 @@
|
||||
#include <unistd.h>
|
||||
#include <lwip/sockets.h>
|
||||
#include <lwip/inet.h>
|
||||
#include "esp_log.h"
|
||||
|
||||
#define TAG "RobotArm"
|
||||
#define ARM_HOST "192.168.68.103"
|
||||
#define ARM_PORT 80
|
||||
#define ARM_PATH "/api/mcp"
|
||||
#define NUM_JOINTS 6
|
||||
@@ -41,68 +40,68 @@ static int seq_len = 0, seq_idx = -1;
|
||||
static bool seq_playing = false;
|
||||
static int seq_play_pos = 0;
|
||||
|
||||
static char arm_host[64] = "";
|
||||
static bool arm_connected = false;
|
||||
|
||||
typedef struct {
|
||||
AppHandle app;
|
||||
lv_obj_t* root;
|
||||
lv_obj_t* content; // scrollable column container
|
||||
lv_obj_t* status;
|
||||
lv_obj_t* sliders[6];
|
||||
lv_obj_t* vals[6];
|
||||
lv_obj_t* seq_label;
|
||||
lv_obj_t* play_label;
|
||||
lv_obj_t* blocks[SEQ_MAX];
|
||||
lv_obj_t* connect_box;
|
||||
lv_obj_t* main_box;
|
||||
lv_obj_t* connect_label;
|
||||
lv_obj_t* connect_spinner;
|
||||
int pend[6];
|
||||
bool has[6];
|
||||
int ticks[6];
|
||||
lv_timer_t* poll;
|
||||
lv_timer_t* seq_timer;
|
||||
lv_timer_t* connect_timer;
|
||||
int connect_attempts;
|
||||
} Ctx;
|
||||
static Ctx* g = NULL;
|
||||
|
||||
static uint16_t my_htons(uint16_t v){ return (v<<8)|(v>>8); }
|
||||
|
||||
static int http_post(const char* host,int port,const char* path,const char* body,char* out,size_t olen){
|
||||
uint32_t ip=ipaddr_addr(host);
|
||||
if(ip==0 || ip==0xFFFFFFFF) return -2;
|
||||
int fd=lwip_socket(AF_INET,SOCK_STREAM,0);
|
||||
if(fd<0) return -1;
|
||||
struct sockaddr_in s; memset(&s,0,sizeof(s));
|
||||
s.sin_family=AF_INET; s.sin_port=my_htons(port); s.sin_addr.s_addr=ipaddr_addr(host);
|
||||
struct timeval tv={5,0};
|
||||
s.sin_family=AF_INET; s.sin_port=my_htons(port); s.sin_addr.s_addr=ip;
|
||||
struct timeval tv={2,0};
|
||||
lwip_setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&tv,sizeof(tv));
|
||||
lwip_setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&tv,sizeof(tv));
|
||||
if(lwip_connect(fd,(struct sockaddr*)&s,sizeof(s))<0){close(fd);return -2;}
|
||||
char hdr[256];
|
||||
int bl=strlen(body);
|
||||
char hdr[256]; int bl=strlen(body);
|
||||
int hl=snprintf(hdr,sizeof(hdr),"POST %s HTTP/1.1\r\nHost: %s:%d\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n",path,host,port,bl);
|
||||
if(lwip_send(fd,hdr,hl,0)<0){close(fd);return -3;}
|
||||
if(lwip_send(fd,body,bl,0)<0){close(fd);return -3;}
|
||||
int tot=0;
|
||||
while(tot<(int)olen-1){int r=lwip_recv(fd,out+tot,olen-1-tot,0); if(r<=0) break; tot+=r;}
|
||||
int tot=0; while(tot<(int)olen-1){int r=lwip_recv(fd,out+tot,olen-1-tot,0); if(r<=0) break; tot+=r;}
|
||||
out[tot]='\0'; close(fd);
|
||||
char* bp=strstr(out,"\r\n\r\n"); if(bp){bp+=4; memmove(out,bp,strlen(bp)+1);}
|
||||
return tot>0?0:-4;
|
||||
}
|
||||
|
||||
static bool jget(const char* js,const char* key,int* out){
|
||||
const char* p=strstr(js,key);
|
||||
if(!p) return false;
|
||||
p+=strlen(key);
|
||||
while(*p && *p!=':'){
|
||||
p++;
|
||||
if(!*p) return false;
|
||||
}
|
||||
p++;
|
||||
const char* p=strstr(js,key); if(!p) return false;
|
||||
p+=strlen(key); while(*p && *p!=':'){p++; if(!*p) return false;} p++;
|
||||
while(*p && (*p==' '||*p=='\t'||*p=='"'||*p=='\\')) p++;
|
||||
int sign=1;
|
||||
if(*p=='-'){sign=-1;p++;}
|
||||
float v=0,frac=0.1f;
|
||||
bool dot=false,got=false;
|
||||
int sign=1; if(*p=='-'){sign=-1;p++;}
|
||||
float v=0,frac=0.1f; bool dot=false,got=false;
|
||||
while(*p){
|
||||
if(*p>='0'&&*p<='9'){got=true; if(!dot) v=v*10+(*p-'0'); else {v+=(*p-'0')*frac; frac*=0.1f;}}
|
||||
else if(*p=='.'&&!dot) dot=true;
|
||||
else break;
|
||||
p++;
|
||||
else if(*p=='.'&&!dot) dot=true; else break; p++;
|
||||
}
|
||||
if(!got) return false;
|
||||
*out=(int)(v*sign+0.5f);
|
||||
return true;
|
||||
*out=(int)(v*sign+0.5f); return true;
|
||||
}
|
||||
static bool parse_state(const char* r,int* b,int* s,int* e,int* p,int* ro,int* gr){
|
||||
int v; bool ok=true;
|
||||
@@ -117,30 +116,24 @@ static bool parse_state(const char* r,int* b,int* s,int* e,int* p,int* ro,int* g
|
||||
static bool rpc_get(int* b,int* s,int* e,int* p,int* ro,int* gr){
|
||||
const char* body="{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"get_arm_state\",\"arguments\":{}}}";
|
||||
char resp[2048]; memset(resp,0,sizeof(resp));
|
||||
if(http_post(ARM_HOST,ARM_PORT,ARM_PATH,body,resp,sizeof(resp))!=0) return false;
|
||||
if(http_post(arm_host,ARM_PORT,ARM_PATH,body,resp,sizeof(resp))!=0) return false;
|
||||
return parse_state(resp,b,s,e,p,ro,gr);
|
||||
}
|
||||
static bool rpc_move(const char* j,int a){
|
||||
char body[300]; snprintf(body,sizeof(body),
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"move_joint\",\"arguments\":{\"joint\":\"%s\",\"angle\":%d,\"duration\":0.5}}}",
|
||||
j,a);
|
||||
char r[1024]; memset(r,0,sizeof(r));
|
||||
return http_post(ARM_HOST,ARM_PORT,ARM_PATH,body,r,sizeof(r))==0;
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"move_joint\",\"arguments\":{\"joint\":\"%s\",\"angle\":%d,\"duration\":0.5}}}",j,a);
|
||||
char r[1024]; memset(r,0,sizeof(r)); return http_post(arm_host,ARM_PORT,ARM_PATH,body,r,sizeof(r))==0;
|
||||
}
|
||||
static bool rpc_move_all(int b,int s,int e,int p,int ro,int gr,float dur){
|
||||
char body[420]; snprintf(body,sizeof(body),
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"move_all_joints\",\"arguments\":{\"base\":%d,\"shoulder\":%d,\"elbow\":%d,\"pitch\":%d,\"roll\":%d,\"gripper\":%d,\"duration\":%.1f}}}",
|
||||
b,s,e,p,ro,gr,dur);
|
||||
char r[1024]; memset(r,0,sizeof(r));
|
||||
int rc=http_post(ARM_HOST,ARM_PORT,ARM_PATH,body,r,sizeof(r));
|
||||
ESP_LOGI(TAG,"move_all %d %d %d rc=%d",b,s,e,rc);
|
||||
return rc==0;
|
||||
char r[1024]; memset(r,0,sizeof(r)); return http_post(arm_host,ARM_PORT,ARM_PATH,body,r,sizeof(r))==0;
|
||||
}
|
||||
static bool rpc_home(void){
|
||||
const char* b="{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"home_arm\",\"arguments\":{\"duration\":1.0}}}";
|
||||
char r[512]; memset(r,0,sizeof(r)); return http_post(ARM_HOST,ARM_PORT,ARM_PATH,b,r,sizeof(r))==0;
|
||||
char r[512]; memset(r,0,sizeof(r)); return http_post(arm_host,ARM_PORT,ARM_PATH,b,r,sizeof(r))==0;
|
||||
}
|
||||
|
||||
static void set_status(const char* t){ if(g&&g->status) lv_label_set_text(g->status,t); }
|
||||
static void apply_ui(void){
|
||||
if(!g) return;
|
||||
@@ -150,7 +143,7 @@ static void apply_ui(void){
|
||||
}
|
||||
}
|
||||
static void refresh_arm(void){
|
||||
set_status("Reading .103...");
|
||||
set_status("Reading...");
|
||||
int b,s,e,p,ro,gr;
|
||||
if(rpc_get(&b,&s,&e,&p,&ro,&gr)){
|
||||
joints[0].value=b; joints[1].value=s; joints[2].value=e;
|
||||
@@ -158,21 +151,21 @@ static void refresh_arm(void){
|
||||
for(int i=0;i<NUM_JOINTS;i++){joints[i].last_sent=joints[i].value; if(g){g->pend[i]=joints[i].value; g->has[i]=false; g->ticks[i]=0;}}
|
||||
apply_ui();
|
||||
char buf[32]; snprintf(buf,sizeof(buf),"B%d S%d E%d",b,s,e); set_status(buf);
|
||||
} else set_status("No .103");
|
||||
} else set_status("No arm");
|
||||
}
|
||||
static void del_ref_cb(lv_timer_t* t){ lv_timer_delete(t); refresh_arm(); }
|
||||
static void init_cb(lv_timer_t* t){ lv_timer_delete(t); refresh_arm(); }
|
||||
static void poll_cb(lv_timer_t* t){
|
||||
(void)t; if(!g) return;
|
||||
(void)t; if(!g || !arm_connected) return;
|
||||
for(int i=0;i<NUM_JOINTS;i++){
|
||||
if(!g->has[i]) continue;
|
||||
g->ticks[i]++; if(g->ticks[i]<3) continue;
|
||||
g->has[i]=false; g->ticks[i]=0;
|
||||
int tgt=g->pend[i]; if(joints[i].last_sent==tgt) continue;
|
||||
joints[i].last_sent=tgt; joints[i].value=tgt;
|
||||
char buf[20]; snprintf(buf,sizeof(buf),"%s %d",joints[i].cute,tgt); set_status(buf);
|
||||
char buf[24]; snprintf(buf,sizeof(buf),"%s %d",joints[i].cute,tgt); set_status(buf);
|
||||
bool ok=rpc_move(joints[i].name,tgt);
|
||||
snprintf(buf,sizeof(buf),"%s %d %s",joints[i].cute,tgt,ok?"ok":"fail"); set_status(buf);
|
||||
snprintf(buf,sizeof(buf),"%s %d %s",joints[i].cute,tgt,ok?"o":"x"); set_status(buf);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -185,8 +178,6 @@ static void slider_cb(lv_event_t* e){
|
||||
g->pend[idx]=v; g->has[idx]=true; g->ticks[idx]=0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── sequencer ── */
|
||||
static void update_seq_ui(void){
|
||||
if(!g) return;
|
||||
if(g->seq_label){
|
||||
@@ -216,26 +207,21 @@ static void load_frame(int fidx){
|
||||
apply_ui(); seq_idx=fidx; update_seq_ui();
|
||||
char b[20]; snprintf(b,sizeof(b),"Frame %d",fidx+1); set_status(b);
|
||||
}
|
||||
static void seq_add_cb(void){ if(seq_len>=SEQ_MAX){set_status("Seq full"); return;} for(int i=0;i<NUM_JOINTS;i++) seq[seq_len].v[i]=joints[i].value; seq_len++; seq_idx=seq_len-1; update_seq_ui(); char b[16]; snprintf(b,sizeof(b),"+ %d",seq_len); set_status(b); }
|
||||
static void seq_add_cb(void){ if(seq_len>=SEQ_MAX){set_status("Full"); return;} for(int i=0;i<NUM_JOINTS;i++) seq[seq_len].v[i]=joints[i].value; seq_len++; seq_idx=seq_len-1; update_seq_ui(); char b[16]; snprintf(b,sizeof(b),"+ %d",seq_len); set_status(b); }
|
||||
static void seq_rem_cb(void){
|
||||
if(seq_len==0||seq_idx<0){set_status("Nothing"); return;}
|
||||
int rem=seq_idx;
|
||||
for(int f=rem;f<seq_len-1;f++) seq[f]=seq[f+1];
|
||||
int rem=seq_idx; for(int f=rem;f<seq_len-1;f++) seq[f]=seq[f+1];
|
||||
seq_len--; if(seq_len==0){seq_idx=-1; update_seq_ui(); set_status("- empty"); return;}
|
||||
if(seq_idx>=seq_len) seq_idx=seq_len-1;
|
||||
load_frame(seq_idx);
|
||||
if(seq_idx>=seq_len) seq_idx=seq_len-1; load_frame(seq_idx);
|
||||
}
|
||||
static void seq_prev_cb(void){ if(seq_len==0) return; int n=(seq_idx<=0)?seq_len-1:seq_idx-1; load_frame(n); }
|
||||
static void seq_next_cb(void){ if(seq_len==0) return; int n=(seq_idx>=seq_len-1)?0:seq_idx+1; load_frame(n); }
|
||||
|
||||
static void seq_timer_cb(lv_timer_t* t){
|
||||
(void)t; if(!seq_playing||seq_len==0) return;
|
||||
int f=seq_play_pos%seq_len;
|
||||
int* v=seq[f].v;
|
||||
int f=seq_play_pos%seq_len; int* v=seq[f].v;
|
||||
if(!rpc_move_all(v[0],v[1],v[2],v[3],v[4],v[5],0.8f)){
|
||||
set_status("Seq fail"); seq_playing=false;
|
||||
if(g&&g->play_label) lv_label_set_text(g->play_label,"Play");
|
||||
return;
|
||||
if(g&&g->play_label) lv_label_set_text(g->play_label,"Play"); return;
|
||||
}
|
||||
for(int i=0;i<NUM_JOINTS;i++){joints[i].value=v[i]; joints[i].last_sent=v[i]; if(g){g->pend[i]=v[i]; g->has[i]=false;}}
|
||||
apply_ui(); seq_idx=f; update_seq_ui();
|
||||
@@ -253,42 +239,155 @@ static void seq_rem_ev(lv_event_t* e){(void)e; seq_rem_cb();}
|
||||
static void seq_prev_ev(lv_event_t* e){(void)e; seq_prev_cb();}
|
||||
static void seq_next_ev(lv_event_t* e){(void)e; seq_next_cb();}
|
||||
static void block_click_cb(lv_event_t* e){int idx=(int)(intptr_t)lv_event_get_user_data(e); if(idx<0||idx>=seq_len) return; load_frame(idx);}
|
||||
|
||||
static void home_cb(lv_event_t* e){(void)e; set_status("Homing..."); if(rpc_home()){lv_timer_create(del_ref_cb,1200,NULL); set_status("Homed :3");} else set_status("Fail");}
|
||||
static void read_cb(lv_event_t* e){(void)e; refresh_arm();}
|
||||
static void open_cb(lv_event_t* e){(void)e; joints[5].value=0; apply_ui(); rpc_move("gripper",0); set_status("Open");}
|
||||
static void close_cb(lv_event_t* e){(void)e; joints[5].value=180; apply_ui(); rpc_move("gripper",180); set_status("Close");}
|
||||
|
||||
static bool try_host(const char* host){
|
||||
char resp[1024]; memset(resp,0,sizeof(resp));
|
||||
if(http_post(host,ARM_PORT,ARM_PATH,"{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"tools/call\",\"params\":{\"name\":\"get_arm_state\",\"arguments\":{}}}",resp,sizeof(resp))==0){
|
||||
if(strstr(resp,"base")){
|
||||
strncpy(arm_host,host,sizeof(arm_host)-1);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
static void show_main_ui(void){
|
||||
if(!g) return;
|
||||
arm_connected=true;
|
||||
if(g->connect_box) lv_obj_add_flag(g->connect_box, LV_OBJ_FLAG_HIDDEN);
|
||||
if(g->main_box) lv_obj_clear_flag(g->main_box, LV_OBJ_FLAG_HIDDEN);
|
||||
if(g->connect_timer){ lv_timer_delete(g->connect_timer); g->connect_timer=NULL; }
|
||||
if(!g->poll) g->poll=lv_timer_create(poll_cb,100,NULL);
|
||||
if(!g->seq_timer) g->seq_timer=lv_timer_create(seq_timer_cb,1300,NULL);
|
||||
lv_timer_create(init_cb,500,NULL);
|
||||
char buf[48]; snprintf(buf,sizeof(buf),"Conn %s",arm_host); set_status(buf);
|
||||
}
|
||||
static void connect_timer_cb(lv_timer_t* t){
|
||||
(void)t; if(!g || arm_connected) return;
|
||||
g->connect_attempts++;
|
||||
char lbl[64];
|
||||
if(g->connect_attempts==1) snprintf(lbl,sizeof(lbl),"mDNS browsing _robotarm._tcp...");
|
||||
else snprintf(lbl,sizeof(lbl),"Searching (%d)...",g->connect_attempts);
|
||||
if(g->connect_label) lv_label_set_text(g->connect_label,lbl);
|
||||
|
||||
// ── 1) mDNS browse for _robotarm._tcp (preferred) ──
|
||||
TtMdnsBrowseResult res; memset(&res,0,sizeof(res));
|
||||
if(tt_mdns_browse("_robotarm","_tcp",2500,10,&res)){
|
||||
for(int i=0;i<res.count;i++){
|
||||
if(res.services[i].primaryAddress[0]){
|
||||
if(try_host(res.services[i].primaryAddress)){
|
||||
show_main_ui(); return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// ── 2) mDNS resolve robotarm.local hostname ──
|
||||
char ipbuf[64]={0};
|
||||
if(tt_mdns_resolve_hostname("robotarm.local",2000,ipbuf) || tt_mdns_resolve_hostname("robotarm",2000,ipbuf)){
|
||||
if(try_host(ipbuf)){ show_main_ui(); return; }
|
||||
}
|
||||
// ── 3) fallback hardcoded (old behavior) ──
|
||||
const char* fallbacks[]={"192.168.68.148","192.168.68.103","192.168.68.102"};
|
||||
int idx = (g->connect_attempts-1) % (int)(sizeof(fallbacks)/sizeof(fallbacks[0]));
|
||||
if(try_host(fallbacks[idx])){ show_main_ui(); return; }
|
||||
|
||||
if(g->connect_attempts>15){
|
||||
if(g->connect_label) lv_label_set_text(g->connect_label,"No arm found.\nMake sure robotarm\nis powered & on WiFi.\nTap Retry.");
|
||||
}
|
||||
}
|
||||
static void retry_cb(lv_event_t* e){(void)e; if(!g) return; g->connect_attempts=0; if(g->connect_label) lv_label_set_text(g->connect_label,"Retrying mDNS...");}
|
||||
|
||||
static void onShow(AppHandle app,void* data,lv_obj_t* parent){
|
||||
(void)data;
|
||||
Ctx* c=(Ctx*)calloc(1,sizeof(Ctx)); if(!c) return;
|
||||
Ctx* c=calloc(1,sizeof(Ctx)); if(!c) return;
|
||||
c->app=app; g=c;
|
||||
for(int i=0;i<NUM_JOINTS;i++){joints[i].last_sent=-1; c->pend[i]=joints[i].value; c->has[i]=false;}
|
||||
arm_host[0]='\0'; arm_connected=false; c->connect_attempts=0;
|
||||
|
||||
lv_obj_t* tb=tt_lvgl_toolbar_create_for_app(parent,app); lv_obj_align(tb,LV_ALIGN_TOP_MID,0,0);
|
||||
|
||||
// Root fills screen below toolbar - NOT scrollable itself, content will be
|
||||
lv_obj_t* root=lv_obj_create(parent);
|
||||
c->root=root;
|
||||
lv_obj_set_size(root,LV_PCT(100),LV_PCT(100));
|
||||
lv_obj_set_style_pad_all(root,2,0); lv_obj_set_style_pad_top(root,34,0);
|
||||
lv_obj_set_style_pad_all(root,2,0); lv_obj_set_style_pad_top(root,36,0);
|
||||
lv_obj_set_style_border_width(root,0,0);
|
||||
lv_obj_set_style_bg_color(root,lv_color_hex(0xFFF8F0),0);
|
||||
lv_obj_set_style_bg_opa(root,LV_OPA_COVER,0);
|
||||
lv_obj_set_scroll_dir(root, LV_DIR_VER);
|
||||
lv_obj_clear_flag(root, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
lv_obj_t* hdr=lv_obj_create(root); lv_obj_set_size(hdr,LV_PCT(100),16);
|
||||
// ── connecting screen (initial visible) ──
|
||||
c->connect_box=lv_obj_create(root);
|
||||
lv_obj_set_size(c->connect_box,LV_PCT(100),LV_PCT(100));
|
||||
lv_obj_set_pos(c->connect_box,0,0);
|
||||
lv_obj_set_style_border_width(c->connect_box,0,0);
|
||||
lv_obj_set_style_bg_opa(c->connect_box,LV_OPA_TRANSP,0);
|
||||
lv_obj_set_style_pad_all(c->connect_box,4,0);
|
||||
lv_obj_clear_flag(c->connect_box, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
lv_obj_t* tl=lv_label_create(c->connect_box);
|
||||
lv_label_set_text(tl,"Finding robotarm...");
|
||||
lv_obj_set_style_text_font(tl,lvgl_get_text_font(FONT_SIZE_LARGE),0);
|
||||
lv_obj_set_style_text_color(tl,lv_color_hex(0x3D2B5A),0);
|
||||
lv_obj_set_pos(tl,4,4);
|
||||
|
||||
c->connect_label=lv_label_create(c->connect_box);
|
||||
lv_label_set_text(c->connect_label,"mDNS: _robotarm._tcp / robotarm.local\nFallback: 192.168.68.148\n\nSearching...");
|
||||
lv_obj_set_style_text_font(c->connect_label,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||
lv_obj_set_style_text_color(c->connect_label,lv_color_hex(0x6A5A7A),0);
|
||||
lv_obj_set_pos(c->connect_label,4,28); lv_obj_set_width(c->connect_label,260);
|
||||
|
||||
c->connect_spinner=lv_spinner_create(c->connect_box);
|
||||
lv_obj_set_size(c->connect_spinner,40,40);
|
||||
lv_obj_set_pos(c->connect_spinner,120,110);
|
||||
|
||||
lv_obj_t* rb=lv_btn_create(c->connect_box);
|
||||
lv_obj_set_size(rb,80,28); lv_obj_set_pos(rb,80,170);
|
||||
lv_obj_set_style_bg_color(rb,lv_color_hex(0xC5F5C5),0); lv_obj_set_style_radius(rb,8,0);
|
||||
lv_obj_t* rbl=lv_label_create(rb); lv_label_set_text(rbl,"Retry");
|
||||
lv_obj_set_style_text_font(rbl,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||
lv_obj_set_style_text_color(rbl,lv_color_hex(0x2A2A5A),0); lv_obj_center(rbl);
|
||||
lv_obj_add_event_cb(rb,retry_cb,LV_EVENT_CLICKED,NULL);
|
||||
|
||||
// ── main scrollable content (hidden until connected) ──
|
||||
// content is the ONLY scrollable container - FIX overall app scroller lost
|
||||
c->content=lv_obj_create(root);
|
||||
lv_obj_set_size(c->content,LV_PCT(100),LV_PCT(100));
|
||||
lv_obj_set_pos(c->content,0,0);
|
||||
lv_obj_set_style_border_width(c->content,0,0);
|
||||
lv_obj_set_style_bg_opa(c->content,LV_OPA_TRANSP,0);
|
||||
lv_obj_set_style_pad_all(c->content,0,0);
|
||||
lv_obj_set_flex_flow(c->content, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_add_flag(c->content, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_set_scrollbar_mode(c->content, LV_SCROLLBAR_MODE_AUTO);
|
||||
|
||||
c->main_box=lv_obj_create(c->content);
|
||||
lv_obj_set_size(c->main_box,LV_PCT(100),LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_border_width(c->main_box,0,0);
|
||||
lv_obj_set_style_bg_opa(c->main_box,LV_OPA_TRANSP,0);
|
||||
lv_obj_set_style_pad_all(c->main_box,0,0);
|
||||
lv_obj_clear_flag(c->main_box, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_add_flag(c->main_box, LV_OBJ_FLAG_HIDDEN);
|
||||
|
||||
lv_obj_t* hdr=lv_obj_create(c->main_box); lv_obj_set_size(hdr,LV_PCT(100),16);
|
||||
lv_obj_set_style_border_width(hdr,0,0); lv_obj_set_style_bg_opa(hdr,LV_OPA_TRANSP,0);
|
||||
lv_obj_set_style_pad_all(hdr,0,0); lv_obj_set_pos(hdr,0,0);
|
||||
lv_obj_set_style_pad_all(hdr,0,0);
|
||||
lv_obj_clear_flag(hdr, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_t* title=lv_label_create(hdr); lv_label_set_text(title,"Robot Arm :3");
|
||||
lv_obj_set_style_text_font(title,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||
lv_obj_set_style_text_color(title,lv_color_hex(0x3D2B5A),0); lv_obj_set_pos(title,2,0);
|
||||
c->status=lv_label_create(hdr); lv_label_set_text(c->status,"Conn .103...");
|
||||
lv_obj_set_style_text_color(title,lv_color_hex(0x3D2B5A),0);
|
||||
lv_obj_set_pos(title,2,0);
|
||||
c->status=lv_label_create(hdr); lv_label_set_text(c->status,"Conn...");
|
||||
lv_obj_set_style_text_font(c->status,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||
lv_obj_set_style_text_color(c->status,lv_color_hex(0xA08090),0); lv_obj_set_pos(c->status,90,0);
|
||||
lv_obj_set_style_text_color(c->status,lv_color_hex(0xA08090),0);
|
||||
lv_obj_set_pos(c->status,90,0);
|
||||
|
||||
lv_obj_t* brow=lv_obj_create(root); lv_obj_set_size(brow,LV_PCT(100),20);
|
||||
lv_obj_t* brow=lv_obj_create(c->main_box); lv_obj_set_size(brow,LV_PCT(100),20);
|
||||
lv_obj_set_style_border_width(brow,0,0); lv_obj_set_style_bg_opa(brow,LV_OPA_TRANSP,0);
|
||||
lv_obj_set_style_pad_all(brow,0,0); lv_obj_set_pos(brow,0,16);
|
||||
lv_obj_set_style_pad_all(brow,0,0);
|
||||
lv_obj_clear_flag(brow, LV_OBJ_FLAG_SCROLLABLE);
|
||||
struct { const char* t; uint32_t col; lv_event_cb_t cb; } btns[]={
|
||||
{"Home",0xFFD6E0,home_cb},{"Read",0xD6E8FF,read_cb},{"Open",0xD5F0D5,open_cb},{"Close",0xFFE8C5,close_cb},};
|
||||
@@ -302,12 +401,12 @@ static void onShow(AppHandle app,void* data,lv_obj_t* parent){
|
||||
lv_obj_add_event_cb(b,btns[i].cb,LV_EVENT_CLICKED,NULL);
|
||||
}
|
||||
|
||||
/* ── large nice vertical sliders: 3 cols, 2x height 22x72 per request ── */
|
||||
lv_obj_t* grid=lv_obj_create(root);
|
||||
lv_obj_t* grid=lv_obj_create(c->main_box);
|
||||
lv_obj_set_size(grid,LV_PCT(100),196);
|
||||
lv_obj_set_style_border_width(grid,0,0); lv_obj_set_style_bg_opa(grid,LV_OPA_TRANSP,0);
|
||||
lv_obj_set_style_pad_all(grid,2,0); lv_obj_set_pos(grid,0,36);
|
||||
lv_obj_set_style_pad_all(grid,2,0);
|
||||
lv_obj_clear_flag(grid, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_set_scrollbar_mode(grid, LV_SCROLLBAR_MODE_OFF);
|
||||
|
||||
for(int i=0;i<NUM_JOINTS;i++){
|
||||
int col=i%3, row=i/3;
|
||||
@@ -320,6 +419,7 @@ static void onShow(AppHandle app,void* data,lv_obj_t* parent){
|
||||
lv_obj_set_style_border_width(card,0,0);
|
||||
lv_obj_set_style_pad_all(card,3,0);
|
||||
lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_set_scrollbar_mode(card, LV_SCROLLBAR_MODE_OFF);
|
||||
|
||||
lv_obj_t* name=lv_label_create(card);
|
||||
lv_label_set_text(name,joints[i].cute);
|
||||
@@ -357,27 +457,22 @@ static void onShow(AppHandle app,void* data,lv_obj_t* parent){
|
||||
lv_obj_add_event_cb(sl,slider_cb,LV_EVENT_VALUE_CHANGED,(void*)(intptr_t)i);
|
||||
}
|
||||
|
||||
/* ── sequencer at bottom, NOT fixed — scrollable with content ──
|
||||
visual: colored blocks, no info, current highlighted, larger buttons */
|
||||
lv_obj_t* seqbar=lv_obj_create(root);
|
||||
lv_obj_t* seqbar=lv_obj_create(c->main_box);
|
||||
lv_obj_set_size(seqbar,316,96);
|
||||
lv_obj_set_pos(seqbar,2,232);
|
||||
lv_obj_clear_flag(seqbar, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_set_style_bg_color(seqbar,lv_color_hex(0xF0E8FF),0);
|
||||
lv_obj_set_style_bg_opa(seqbar,LV_OPA_90,0);
|
||||
lv_obj_set_style_radius(seqbar,12,0);
|
||||
lv_obj_set_style_border_width(seqbar,1,0);
|
||||
lv_obj_set_style_border_color(seqbar,lv_color_hex(0xD0C0E0),0);
|
||||
lv_obj_set_style_pad_all(seqbar,4,0);
|
||||
lv_obj_clear_flag(seqbar, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
lv_obj_t* seq_t=lv_label_create(seqbar); lv_label_set_text(seq_t,"Seq");
|
||||
lv_obj_set_style_text_font(seq_t,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||
lv_obj_set_style_text_color(seq_t,lv_color_hex(0x3D2B5A),0); lv_obj_set_pos(seq_t,2,0);
|
||||
|
||||
c->seq_label=lv_label_create(seqbar); lv_label_set_text(c->seq_label,"empty");
|
||||
lv_obj_set_style_text_font(c->seq_label,lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||
lv_obj_set_style_text_color(c->seq_label,lv_color_hex(0x6A5A7A),0); lv_obj_set_pos(c->seq_label,30,0);
|
||||
|
||||
struct { const char* t; uint32_t col; lv_event_cb_t cb; int play; } sbtns[]={
|
||||
{"-",0xFFB7B7,seq_rem_ev,0},{"<",0xD6E8FF,seq_prev_ev,0},
|
||||
{"Play",0xC5F5C5,seq_play_cb,1},{">",0xD6E8FF,seq_next_ev,0},{"+",0xFFE8A0,seq_add_ev,0},};
|
||||
@@ -394,7 +489,6 @@ static void onShow(AppHandle app,void* data,lv_obj_t* parent){
|
||||
lv_obj_set_style_text_color(l,lv_color_hex(0x2A2A5A),0); lv_obj_center(l);
|
||||
lv_obj_add_event_cb(b,sbtns[i].cb,LV_EVENT_CLICKED,NULL);
|
||||
}
|
||||
|
||||
uint32_t blk_cols[16]={
|
||||
0xFF8FA8,0x8FB6FF,0xFFB86A,0x88D488,0xB088FF,0xFFD060,0xFF7AA2,0x7AC8FF,
|
||||
0xFDBA74,0x86EFAC,0xA78BFA,0xFDE68A,0xFCA5A5,0x93C5FD,0xBEF264,0xFDA4AF
|
||||
@@ -412,9 +506,8 @@ static void onShow(AppHandle app,void* data,lv_obj_t* parent){
|
||||
}
|
||||
update_seq_ui();
|
||||
|
||||
c->poll=lv_timer_create(poll_cb,100,NULL);
|
||||
c->seq_timer=lv_timer_create(seq_timer_cb,1300,NULL);
|
||||
lv_timer_create(init_cb,600,NULL);
|
||||
c->connect_timer=lv_timer_create(connect_timer_cb, 1000, NULL);
|
||||
connect_timer_cb(NULL);
|
||||
}
|
||||
|
||||
static void onHide(AppHandle app,void* data){
|
||||
@@ -422,8 +515,9 @@ static void onHide(AppHandle app,void* data){
|
||||
if(g){
|
||||
if(g->poll) lv_timer_delete(g->poll);
|
||||
if(g->seq_timer) lv_timer_delete(g->seq_timer);
|
||||
if(g->connect_timer) lv_timer_delete(g->connect_timer);
|
||||
free(g); g=NULL;
|
||||
}
|
||||
seq_playing=false;
|
||||
seq_playing=false; arm_connected=false;
|
||||
}
|
||||
int main(int argc,char* argv[]){(void)argc;(void)argv; tt_app_register((AppRegistration){.onShow=onShow,.onHide=onHide}); return 0;}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
/**
|
||||
* Voice Recorder App for Tactility OS
|
||||
*
|
||||
* Records voice memos (16kHz 16-bit Mono WAV) to /sdcard/memos/
|
||||
* Uses audio-stream API with resampling (native codec 44100 -> app 16000)
|
||||
* Records voice memos (16kHz 16-bit Mono WAV format) to the SD card (/sdcard/memos/)
|
||||
* and plays them back.
|
||||
*/
|
||||
|
||||
#include <tt_app.h>
|
||||
#include <tt_lvgl.h>
|
||||
#include <tt_lvgl_toolbar.h>
|
||||
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/drivers/audio_stream.h>
|
||||
#include <tactility/drivers/i2s_controller.h>
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
@@ -41,9 +42,7 @@ typedef enum {
|
||||
} AudioState;
|
||||
|
||||
typedef struct {
|
||||
struct Device* stream_dev;
|
||||
AudioStreamHandle input_handle;
|
||||
AudioStreamHandle output_handle;
|
||||
struct Device* i2s_dev;
|
||||
uint8_t* audio_buf;
|
||||
AudioState state;
|
||||
int volume;
|
||||
@@ -66,19 +65,19 @@ typedef struct {
|
||||
} AppCtx;
|
||||
|
||||
typedef struct __attribute__((packed)) {
|
||||
char riff[4];
|
||||
uint32_t overall_size;
|
||||
char wave[4];
|
||||
char fmt_chunk_marker[4];
|
||||
uint32_t length_of_fmt;
|
||||
uint16_t format_type;
|
||||
uint16_t channels;
|
||||
uint32_t sample_rate;
|
||||
uint32_t byterate;
|
||||
uint16_t block_align;
|
||||
uint16_t bits_per_sample;
|
||||
char data_chunk_header[4];
|
||||
uint32_t data_size;
|
||||
char riff[4]; // "RIFF"
|
||||
uint32_t overall_size; // file size - 8
|
||||
char wave[4]; // "WAVE"
|
||||
char fmt_chunk_marker[4]; // "fmt "
|
||||
uint32_t length_of_fmt; // 16 for PCM
|
||||
uint16_t format_type; // 1 for PCM
|
||||
uint16_t channels; // 1 for mono
|
||||
uint32_t sample_rate; // 16000
|
||||
uint32_t byterate; // sample_rate * channels * (bits_per_sample / 8)
|
||||
uint16_t block_align; // channels * (bits_per_sample / 8)
|
||||
uint16_t bits_per_sample; // 16
|
||||
char data_chunk_header[4]; // "data"
|
||||
uint32_t data_size; // data size in bytes
|
||||
} WavHeader;
|
||||
|
||||
static AppCtx g_ctx;
|
||||
@@ -88,20 +87,6 @@ static void update_ui(AppCtx* ctx);
|
||||
static void refresh_memo_list(AppCtx* ctx);
|
||||
static void on_memo_selected(lv_event_t* e);
|
||||
|
||||
static bool find_audio_stream_device(AppCtx* ctx) {
|
||||
struct Device* dev = device_find_by_name("audio-stream");
|
||||
if (dev) {
|
||||
ctx->stream_dev = dev;
|
||||
return true;
|
||||
}
|
||||
dev = device_find_first_by_type(&AUDIO_STREAM_TYPE);
|
||||
if (dev) {
|
||||
ctx->stream_dev = dev;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* ─── WAV Header Writer ─── */
|
||||
static void write_wav_header(FILE* f, uint32_t sample_rate, uint16_t bits_per_sample, uint16_t channels, uint32_t data_size) {
|
||||
WavHeader header;
|
||||
@@ -129,8 +114,10 @@ static void get_next_memo_filename(char* out_filename, size_t max_len) {
|
||||
struct tm timeinfo;
|
||||
localtime_r(&rawtime, &timeinfo);
|
||||
|
||||
// Format: YYYYMMDD_HHMMSS (e.g., 20260629_232202)
|
||||
strftime(datetime_buf, sizeof(datetime_buf), "%Y%m%d_%H%M%S", &timeinfo);
|
||||
|
||||
// Default unique name candidate
|
||||
snprintf(out_filename, max_len, "memo_%s.wav", datetime_buf);
|
||||
|
||||
char filepath[256];
|
||||
@@ -138,21 +125,24 @@ static void get_next_memo_filename(char* out_filename, size_t max_len) {
|
||||
|
||||
struct stat st;
|
||||
if (stat(filepath, &st) != 0) {
|
||||
// File does not exist, safe to use
|
||||
return;
|
||||
}
|
||||
|
||||
// File exists, let's search for a unique name by appending _N
|
||||
int suffix = 1;
|
||||
while (1) {
|
||||
snprintf(out_filename, max_len, "memo_%s_%d.wav", datetime_buf, suffix);
|
||||
snprintf(filepath, sizeof(filepath), "/sdcard/memos/%s", out_filename);
|
||||
if (stat(filepath, &st) != 0) {
|
||||
// Found a unique name!
|
||||
return;
|
||||
}
|
||||
suffix++;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Record Task (audio-stream input, resampled to 16k mono) ─── */
|
||||
/* ─── Record Task ─── */
|
||||
static void record_task(void* arg) {
|
||||
AppCtx* ctx = (AppCtx*)arg;
|
||||
char filepath[256];
|
||||
@@ -174,24 +164,29 @@ static void record_task(void* arg) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Write default header
|
||||
write_wav_header(f, SAMPLE_RATE, BITS_PER_SAMPLE, 1, 0);
|
||||
|
||||
// Open input stream via audio-stream (resampler provides 16k mono regardless of codec native rate)
|
||||
struct AudioStreamConfig in_cfg = {
|
||||
/* Configure I2S for recording - RLCD fix: ES7210 outputs stereo (2 mics)
|
||||
* even when we want mono file. Working MicroPython uses I2S.STEREO for RX
|
||||
* then extracts left channel. If we request MONO, driver uses MONO slot mode
|
||||
* but ES7210 still sends 2 slots, causing channel mismatch / low pitch.
|
||||
* So request STEREO from I2S and downmix to mono in the loop.
|
||||
* See audio_util.py: i2s = I2S(..., format=I2S.STEREO, rate=16000) */
|
||||
struct I2sConfig cfg = {
|
||||
.communication_format = I2S_FORMAT_STAND_I2S,
|
||||
.sample_rate = SAMPLE_RATE,
|
||||
.bits_per_sample = BITS_PER_SAMPLE,
|
||||
.channels = 1
|
||||
.channel_left = 0,
|
||||
.channel_right = 1 // STEREO for ES7210 dual mic - we downmix to mono below
|
||||
};
|
||||
|
||||
error_t err = audio_stream_open_input(ctx->stream_dev, &in_cfg, &ctx->input_handle);
|
||||
if (err == ERROR_NONE) {
|
||||
// Ensure mic is unmuted and at max gain (ES8311 0..24dB)
|
||||
audio_stream_set_mute(ctx->stream_dev, AUDIO_CODEC_DIR_INPUT, false);
|
||||
audio_stream_set_volume(ctx->stream_dev, AUDIO_CODEC_DIR_INPUT, 100.0f);
|
||||
ESP_LOGI(TAG, "Mic unmuted, gain 100%%");
|
||||
}
|
||||
device_lock(ctx->i2s_dev);
|
||||
error_t err = i2s_controller_set_config(ctx->i2s_dev, &cfg);
|
||||
device_unlock(ctx->i2s_dev);
|
||||
|
||||
if (err != ERROR_NONE) {
|
||||
ESP_LOGE(TAG, "audio_stream_open_input failed: %d", err);
|
||||
ESP_LOGE(TAG, "Failed to set I2S config: %d", err);
|
||||
fclose(f);
|
||||
unlink(filepath);
|
||||
tt_lvgl_lock(portMAX_DELAY);
|
||||
@@ -204,47 +199,68 @@ static void record_task(void* arg) {
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Recording started -> %s (via audio-stream %u Hz)", filepath, (unsigned)SAMPLE_RATE);
|
||||
ESP_LOGI(TAG, "Recording started -> %s", filepath);
|
||||
|
||||
size_t total_written = 0;
|
||||
uint32_t start_time = xTaskGetTickCount();
|
||||
|
||||
while (ctx->state == STATE_RECORDING) {
|
||||
size_t bytes_read = 0;
|
||||
err = audio_stream_read(ctx->input_handle, ctx->audio_buf, AUDIO_BUF_SIZE, &bytes_read, pdMS_TO_TICKS(200));
|
||||
err = i2s_controller_read(ctx->i2s_dev, ctx->audio_buf, AUDIO_BUF_SIZE, &bytes_read, pdMS_TO_TICKS(100));
|
||||
if (err == ERROR_NONE && bytes_read > 0) {
|
||||
// Data already 16k mono from audio-stream resampler, no downmix needed
|
||||
size_t written = fwrite(ctx->audio_buf, 1, bytes_read, f);
|
||||
if (written != bytes_read) {
|
||||
ESP_LOGE(TAG, "SD write error: wrote %d of %d", (int)written, (int)bytes_read);
|
||||
// ES7210 stereo -> mono downmix: extract left channel (every other 16-bit sample)
|
||||
// Working MP: for i in 0..bytes_read step 4: mono[j:j+2]=buffer[i:i+2]
|
||||
// CHUNK_BYTES=1024 mono, but we read AUDIO_BUF_SIZE stereo (4096) then downmix to half
|
||||
size_t mono_bytes = bytes_read / 2;
|
||||
// In-place downmix using separate temp? Use static tmp buffer via audio_buf + mono offset
|
||||
// Easiest: convert in-place from end to start to avoid overwrite
|
||||
// Stereo layout: L0 R0 L1 R1 ... each 2 bytes, total bytes_read
|
||||
// We want L0 L1 L2 ... -> mono_bytes
|
||||
// Do backward copy: dest index = mono_bytes-2, src left = bytes_read-4
|
||||
// This avoids needing second buffer
|
||||
for (int src = (int)bytes_read - 4, dst = (int)mono_bytes - 2; src >= 0 && dst >= 0; src -= 4, dst -= 2) {
|
||||
ctx->audio_buf[dst] = ctx->audio_buf[src];
|
||||
ctx->audio_buf[dst+1] = ctx->audio_buf[src+1];
|
||||
}
|
||||
size_t written = fwrite(ctx->audio_buf, 1, mono_bytes, f);
|
||||
if (written != mono_bytes) {
|
||||
ESP_LOGE(TAG, "SD write error: wrote %d of %d", (int)written, (int)mono_bytes);
|
||||
break;
|
||||
}
|
||||
total_written += written;
|
||||
}
|
||||
|
||||
// Update elapsed time in UI
|
||||
uint32_t elapsed_sec = (xTaskGetTickCount() - start_time) / configTICK_RATE_HZ;
|
||||
char status_buf[64];
|
||||
snprintf(status_buf, sizeof(status_buf), "🔴 Recording... %02u:%02u", (unsigned)(elapsed_sec / 60), (unsigned)(elapsed_sec % 60));
|
||||
|
||||
// Animate progress bar using modulo
|
||||
int bar_val = (int)(((xTaskGetTickCount() - start_time) * 100) / (configTICK_RATE_HZ * 300)); // 5 min max
|
||||
|
||||
tt_lvgl_lock(portMAX_DELAY);
|
||||
lv_label_set_text(ctx->lbl_status, status_buf);
|
||||
lv_bar_set_value(ctx->bar_progress, (int)((elapsed_sec * 100 / 300)) % 100, LV_ANIM_OFF);
|
||||
lv_bar_set_value(ctx->bar_progress, bar_val % 100, LV_ANIM_OFF);
|
||||
tt_lvgl_unlock();
|
||||
}
|
||||
|
||||
audio_stream_close(ctx->input_handle);
|
||||
ctx->input_handle = NULL;
|
||||
|
||||
// Rewrite WAV header with actual recorded size
|
||||
fseek(f, 0, SEEK_SET);
|
||||
write_wav_header(f, SAMPLE_RATE, BITS_PER_SAMPLE, 1, total_written);
|
||||
fclose(f);
|
||||
|
||||
ESP_LOGI(TAG, "Recording finished: %u bytes", (unsigned)total_written);
|
||||
|
||||
// Reset I2S controller to stop/release DMA channel
|
||||
device_lock(ctx->i2s_dev);
|
||||
i2s_controller_reset(ctx->i2s_dev);
|
||||
device_unlock(ctx->i2s_dev);
|
||||
|
||||
tt_lvgl_lock(portMAX_DELAY);
|
||||
ctx->state = STATE_IDLE;
|
||||
refresh_memo_list(ctx);
|
||||
|
||||
|
||||
// Select the new file automatically
|
||||
ctx->selected_index = -1;
|
||||
for (int i = 0; i < ctx->memo_count; i++) {
|
||||
if (strcmp(ctx->memos[i], filename) == 0) {
|
||||
@@ -252,7 +268,7 @@ static void record_task(void* arg) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
update_ui(ctx);
|
||||
tt_lvgl_unlock();
|
||||
|
||||
@@ -260,7 +276,7 @@ static void record_task(void* arg) {
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
/* ─── Playback Task (audio-stream output with resampling) ─── */
|
||||
/* ─── Playback Task ─── */
|
||||
static void play_task(void* arg) {
|
||||
AppCtx* ctx = (AppCtx*)arg;
|
||||
if (ctx->selected_index < 0 || ctx->selected_index >= ctx->memo_count) {
|
||||
@@ -289,6 +305,7 @@ static void play_task(void* arg) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Read and parse WAV header
|
||||
WavHeader header;
|
||||
if (fread(&header, 1, sizeof(WavHeader), f) != sizeof(WavHeader)) {
|
||||
ESP_LOGE(TAG, "Failed to read WAV header");
|
||||
@@ -303,6 +320,7 @@ static void play_task(void* arg) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify file structure
|
||||
if (memcmp(header.riff, "RIFF", 4) != 0 || memcmp(header.wave, "WAVE", 4) != 0) {
|
||||
ESP_LOGE(TAG, "Invalid WAV format");
|
||||
fclose(f);
|
||||
@@ -316,16 +334,21 @@ static void play_task(void* arg) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* Open output stream via audio-stream (resampler converts file rate -> native 44100) */
|
||||
struct AudioStreamConfig out_cfg = {
|
||||
/* Configure I2S based on file metadata */
|
||||
struct I2sConfig cfg = {
|
||||
.communication_format = I2S_FORMAT_STAND_I2S,
|
||||
.sample_rate = header.sample_rate,
|
||||
.bits_per_sample = header.bits_per_sample,
|
||||
.channels = header.channels
|
||||
.channel_left = 0,
|
||||
.channel_right = (header.channels == 2) ? 1 : I2S_CHANNEL_NONE
|
||||
};
|
||||
|
||||
error_t err = audio_stream_open_output(ctx->stream_dev, &out_cfg, &ctx->output_handle);
|
||||
device_lock(ctx->i2s_dev);
|
||||
error_t err = i2s_controller_set_config(ctx->i2s_dev, &cfg);
|
||||
device_unlock(ctx->i2s_dev);
|
||||
|
||||
if (err != ERROR_NONE) {
|
||||
ESP_LOGE(TAG, "audio_stream_open_output failed for playback: %d (rate=%u)", err, (unsigned)header.sample_rate);
|
||||
ESP_LOGE(TAG, "Failed to set I2S config for playback: %d", err);
|
||||
fclose(f);
|
||||
tt_lvgl_lock(portMAX_DELAY);
|
||||
ctx->state = STATE_IDLE;
|
||||
@@ -337,7 +360,7 @@ static void play_task(void* arg) {
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Playing via audio-stream: %s, rate=%u Hz, ch=%u, size=%u",
|
||||
ESP_LOGI(TAG, "Playing: %s, rate=%u Hz, channels=%u, size=%u bytes",
|
||||
filepath, (unsigned)header.sample_rate, header.channels, (unsigned)header.data_size);
|
||||
|
||||
size_t total_played = 0;
|
||||
@@ -348,35 +371,40 @@ static void play_task(void* arg) {
|
||||
fseek(f, sizeof(WavHeader), SEEK_SET);
|
||||
}
|
||||
|
||||
bool out_open = true;
|
||||
bool i2s_configured = true;
|
||||
|
||||
while (total_played < data_size && ctx->state != STATE_IDLE) {
|
||||
if (ctx->state == STATE_PAUSED) {
|
||||
if (out_open) {
|
||||
audio_stream_close(ctx->output_handle);
|
||||
ctx->output_handle = NULL;
|
||||
out_open = false;
|
||||
if (i2s_configured) {
|
||||
// Reset I2S controller to stop DMA immediately when pausing
|
||||
device_lock(ctx->i2s_dev);
|
||||
i2s_controller_reset(ctx->i2s_dev);
|
||||
device_unlock(ctx->i2s_dev);
|
||||
i2s_configured = false;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(50));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!out_open) {
|
||||
err = audio_stream_open_output(ctx->stream_dev, &out_cfg, &ctx->output_handle);
|
||||
if (!i2s_configured) {
|
||||
// Reconfigure I2S when resuming
|
||||
device_lock(ctx->i2s_dev);
|
||||
err = i2s_controller_set_config(ctx->i2s_dev, &cfg);
|
||||
device_unlock(ctx->i2s_dev);
|
||||
if (err != ERROR_NONE) {
|
||||
ESP_LOGE(TAG, "Failed to reopen audio_stream on resume: %d", err);
|
||||
ESP_LOGE(TAG, "Failed to reconfigure I2S on resume: %d", err);
|
||||
break;
|
||||
}
|
||||
out_open = true;
|
||||
i2s_configured = true;
|
||||
}
|
||||
|
||||
size_t to_read = (data_size - total_played < CHUNK_BYTES) ? (data_size - total_played) : CHUNK_BYTES;
|
||||
size_t read_bytes = fread(ctx->audio_buf, 1, to_read, f);
|
||||
if (read_bytes == 0) {
|
||||
break;
|
||||
break; // EOF
|
||||
}
|
||||
|
||||
// Volume scaling
|
||||
// Scaling Volume
|
||||
int vol = ctx->volume;
|
||||
int16_t* samples_ptr = (int16_t*)ctx->audio_buf;
|
||||
size_t sample_count = read_bytes / sizeof(int16_t);
|
||||
@@ -389,19 +417,22 @@ static void play_task(void* arg) {
|
||||
bool write_err = false;
|
||||
while (offset < read_bytes && ctx->state == STATE_PLAYING) {
|
||||
size_t written = 0;
|
||||
err = audio_stream_write(ctx->output_handle, ctx->audio_buf + offset, read_bytes - offset, &written, pdMS_TO_TICKS(500));
|
||||
err = i2s_controller_write(ctx->i2s_dev, ctx->audio_buf + offset, read_bytes - offset, &written, pdMS_TO_TICKS(100));
|
||||
if (err != ERROR_NONE || written == 0) {
|
||||
ESP_LOGE(TAG, "audio_stream write error: %d", err);
|
||||
ESP_LOGE(TAG, "I2S playback write error: %d", err);
|
||||
write_err = true;
|
||||
break;
|
||||
}
|
||||
offset += written;
|
||||
}
|
||||
|
||||
if (write_err) break;
|
||||
if (write_err) {
|
||||
break;
|
||||
}
|
||||
|
||||
total_played += read_bytes;
|
||||
|
||||
// UI progress update
|
||||
int pct = (int)((total_played * 100) / data_size);
|
||||
uint32_t bytes_per_sec = header.sample_rate * header.channels * (header.bits_per_sample / 8);
|
||||
uint32_t elapsed_sec = total_played / bytes_per_sec;
|
||||
@@ -421,10 +452,10 @@ static void play_task(void* arg) {
|
||||
fclose(f);
|
||||
ESP_LOGI(TAG, "Playback task complete");
|
||||
|
||||
if (out_open && ctx->output_handle) {
|
||||
audio_stream_close(ctx->output_handle);
|
||||
ctx->output_handle = NULL;
|
||||
}
|
||||
// Reset I2S controller to stop/release DMA channel and prevent looping noise
|
||||
device_lock(ctx->i2s_dev);
|
||||
i2s_controller_reset(ctx->i2s_dev);
|
||||
device_unlock(ctx->i2s_dev);
|
||||
|
||||
tt_lvgl_lock(portMAX_DELAY);
|
||||
ctx->state = STATE_IDLE;
|
||||
@@ -439,12 +470,12 @@ static void play_task(void* arg) {
|
||||
static void on_record_click(lv_event_t* e) {
|
||||
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
|
||||
if (ctx->state != STATE_IDLE) return;
|
||||
if (ctx->stream_dev == NULL) return;
|
||||
if (ctx->i2s_dev == NULL) return;
|
||||
|
||||
ctx->state = STATE_RECORDING;
|
||||
update_ui(ctx);
|
||||
|
||||
xTaskCreate(record_task, "voice_rec", 8192, ctx, 5, &ctx->task_handle);
|
||||
xTaskCreate(record_task, "voice_rec", 4096, ctx, 5, &ctx->task_handle);
|
||||
}
|
||||
|
||||
static void on_play_pause_click(lv_event_t* e) {
|
||||
@@ -454,7 +485,7 @@ static void on_play_pause_click(lv_event_t* e) {
|
||||
if (ctx->state == STATE_IDLE) {
|
||||
ctx->state = STATE_PLAYING;
|
||||
update_ui(ctx);
|
||||
xTaskCreate(play_task, "voice_play", 8192, ctx, 5, &ctx->task_handle);
|
||||
xTaskCreate(play_task, "voice_play", 4096, ctx, 5, &ctx->task_handle);
|
||||
} else if (ctx->state == STATE_PLAYING) {
|
||||
ctx->state = STATE_PAUSED;
|
||||
update_ui(ctx);
|
||||
@@ -492,12 +523,14 @@ static void on_memo_selected(lv_event_t* e) {
|
||||
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
|
||||
lv_obj_t* btn = lv_event_get_target(e);
|
||||
|
||||
// Clear check states from other entries
|
||||
uint32_t child_cnt = lv_obj_get_child_cnt(ctx->lst_memos);
|
||||
for (uint32_t i = 0; i < child_cnt; i++) {
|
||||
lv_obj_t* child = lv_obj_get_child(ctx->lst_memos, i);
|
||||
lv_obj_clear_state(child, LV_STATE_CHECKED);
|
||||
}
|
||||
|
||||
// Apply check state to the clicked memo
|
||||
lv_obj_add_state(btn, LV_STATE_CHECKED);
|
||||
|
||||
const char* text = lv_list_get_btn_text(ctx->lst_memos, btn);
|
||||
@@ -535,6 +568,7 @@ static void refresh_memo_list(AppCtx* ctx) {
|
||||
}
|
||||
closedir(dir);
|
||||
|
||||
// Simple bubble sort
|
||||
for (int i = 0; i < ctx->memo_count - 1; i++) {
|
||||
for (int j = 0; j < ctx->memo_count - i - 1; j++) {
|
||||
if (strcmp(ctx->memos[j], ctx->memos[j + 1]) > 0) {
|
||||
@@ -546,6 +580,7 @@ static void refresh_memo_list(AppCtx* ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
// Populate UI List
|
||||
for (int i = 0; i < ctx->memo_count; i++) {
|
||||
lv_obj_t* btn = lv_list_add_btn(ctx->lst_memos, LV_SYMBOL_AUDIO, ctx->memos[i]);
|
||||
lv_obj_add_event_cb(btn, on_memo_selected, LV_EVENT_CLICKED, ctx);
|
||||
@@ -589,7 +624,7 @@ static void update_ui(AppCtx* ctx) {
|
||||
if (ctx->selected_index >= 0 && ctx->selected_index < ctx->memo_count) {
|
||||
lv_obj_clear_state(ctx->btn_play_pause, LV_STATE_DISABLED);
|
||||
lv_obj_clear_state(ctx->btn_delete, LV_STATE_DISABLED);
|
||||
|
||||
|
||||
char status_buf[128];
|
||||
snprintf(status_buf, sizeof(status_buf), "Selected: %s", ctx->memos[ctx->selected_index]);
|
||||
lv_label_set_text(ctx->lbl_status, status_buf);
|
||||
@@ -612,100 +647,115 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
||||
g_ctx.volume = 80;
|
||||
g_ctx.selected_index = -1;
|
||||
|
||||
// Create the memos storage directory if missing
|
||||
mkdir("/sdcard/memos", 0755);
|
||||
|
||||
// Allocate audio buffer
|
||||
g_ctx.audio_buf = (uint8_t*)malloc(AUDIO_BUF_SIZE);
|
||||
if (g_ctx.audio_buf == NULL) {
|
||||
ESP_LOGE(TAG, "Failed to allocate audio buffer");
|
||||
}
|
||||
|
||||
if (!find_audio_stream_device(&g_ctx)) {
|
||||
ESP_LOGE(TAG, "audio-stream device not found!");
|
||||
// Locate I2S hardware channel
|
||||
g_ctx.i2s_dev = device_find_by_name("i2s0");
|
||||
if (g_ctx.i2s_dev == NULL) {
|
||||
ESP_LOGE(TAG, "I2S device 'i2s0' not found!");
|
||||
}
|
||||
|
||||
// ─── UI Setup ───
|
||||
lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, app);
|
||||
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
|
||||
|
||||
// Premium Player Card
|
||||
lv_obj_t* card = lv_obj_create(parent);
|
||||
lv_obj_set_size(card, lv_pct(95), lv_pct(88));
|
||||
lv_obj_align(card, LV_ALIGN_CENTER, 0, 12);
|
||||
lv_obj_set_style_radius(card, 15, 0);
|
||||
lv_obj_set_style_bg_color(card, lv_color_hex(0x1E1E2E), 0);
|
||||
lv_obj_set_style_border_color(card, lv_color_hex(0x313244), 0);
|
||||
lv_obj_set_style_bg_color(card, lv_color_hex(0x1E1E2E), 0); // mocha base
|
||||
lv_obj_set_style_border_color(card, lv_color_hex(0x313244), 0); // mocha surface0
|
||||
lv_obj_set_style_border_width(card, 2, 0);
|
||||
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(card, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_all(card, 10, 0);
|
||||
lv_obj_set_style_pad_gap(card, 8, 0);
|
||||
|
||||
// Status Area
|
||||
g_ctx.lbl_status = lv_label_create(card);
|
||||
lv_obj_set_width(g_ctx.lbl_status, lv_pct(95));
|
||||
lv_obj_set_style_text_align(g_ctx.lbl_status, LV_TEXT_ALIGN_CENTER, 0);
|
||||
lv_obj_set_style_text_color(g_ctx.lbl_status, lv_color_hex(0xCDD6F4), 0);
|
||||
lv_obj_set_style_text_color(g_ctx.lbl_status, lv_color_hex(0xCDD6F4), 0); // mocha text
|
||||
lv_label_set_text(g_ctx.lbl_status, "Scanning memos...");
|
||||
|
||||
// Progress Bar
|
||||
g_ctx.bar_progress = lv_bar_create(card);
|
||||
lv_obj_set_size(g_ctx.bar_progress, lv_pct(90), 8);
|
||||
lv_bar_set_range(g_ctx.bar_progress, 0, 100);
|
||||
lv_bar_set_value(g_ctx.bar_progress, 0, LV_ANIM_OFF);
|
||||
lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x45475A), LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x89B4FA), LV_PART_INDICATOR);
|
||||
lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x45475A), LV_PART_MAIN); // mocha surface1
|
||||
lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x89B4FA), LV_PART_INDICATOR); // mocha blue
|
||||
|
||||
// Memos List
|
||||
g_ctx.lst_memos = lv_list_create(card);
|
||||
lv_obj_set_size(g_ctx.lst_memos, lv_pct(95), 85);
|
||||
lv_obj_set_style_bg_color(g_ctx.lst_memos, lv_color_hex(0x181825), 0);
|
||||
lv_obj_set_style_bg_color(g_ctx.lst_memos, lv_color_hex(0x181825), 0); // mocha mantle
|
||||
lv_obj_set_style_border_color(g_ctx.lst_memos, lv_color_hex(0x313244), 0);
|
||||
lv_obj_set_style_border_width(g_ctx.lst_memos, 1, 0);
|
||||
lv_obj_set_style_radius(g_ctx.lst_memos, 8, 0);
|
||||
|
||||
// Controls Box
|
||||
lv_obj_t* ctrl_box = lv_obj_create(card);
|
||||
lv_obj_remove_style_all(ctrl_box);
|
||||
lv_obj_set_size(ctrl_box, lv_pct(95), 40);
|
||||
lv_obj_set_flex_flow(ctrl_box, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_flex_align(ctrl_box, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
|
||||
// Rec Button
|
||||
g_ctx.btn_record = lv_btn_create(ctrl_box);
|
||||
lv_obj_set_size(g_ctx.btn_record, 45, 32);
|
||||
lv_obj_set_style_radius(g_ctx.btn_record, 8, 0);
|
||||
lv_obj_set_style_bg_color(g_ctx.btn_record, lv_color_hex(0xF38BA8), 0);
|
||||
lv_obj_set_style_bg_color(g_ctx.btn_record, lv_color_hex(0xF38BA8), 0); // mocha red
|
||||
lv_obj_set_style_text_color(g_ctx.btn_record, lv_color_hex(0x11111B), 0);
|
||||
lv_obj_t* lbl_rec = lv_label_create(g_ctx.btn_record);
|
||||
lv_label_set_text(lbl_rec, LV_SYMBOL_AUDIO);
|
||||
lv_obj_center(lbl_rec);
|
||||
lv_obj_add_event_cb(g_ctx.btn_record, on_record_click, LV_EVENT_CLICKED, &g_ctx);
|
||||
|
||||
// Play/Pause Button
|
||||
g_ctx.btn_play_pause = lv_btn_create(ctrl_box);
|
||||
lv_obj_set_size(g_ctx.btn_play_pause, 45, 32);
|
||||
lv_obj_set_style_radius(g_ctx.btn_play_pause, 8, 0);
|
||||
lv_obj_set_style_bg_color(g_ctx.btn_play_pause, lv_color_hex(0x89B4FA), 0);
|
||||
lv_obj_set_style_bg_color(g_ctx.btn_play_pause, lv_color_hex(0x89B4FA), 0); // mocha blue
|
||||
lv_obj_set_style_text_color(g_ctx.btn_play_pause, lv_color_hex(0x11111B), 0);
|
||||
lv_obj_t* lbl_play = lv_label_create(g_ctx.btn_play_pause);
|
||||
lv_label_set_text(lbl_play, LV_SYMBOL_PLAY);
|
||||
lv_obj_center(lbl_play);
|
||||
lv_obj_add_event_cb(g_ctx.btn_play_pause, on_play_pause_click, LV_EVENT_CLICKED, &g_ctx);
|
||||
|
||||
// Stop Button
|
||||
g_ctx.btn_stop = lv_btn_create(ctrl_box);
|
||||
lv_obj_set_size(g_ctx.btn_stop, 45, 32);
|
||||
lv_obj_set_style_radius(g_ctx.btn_stop, 8, 0);
|
||||
lv_obj_set_style_bg_color(g_ctx.btn_stop, lv_color_hex(0xBAC2DE), 0);
|
||||
lv_obj_set_style_bg_color(g_ctx.btn_stop, lv_color_hex(0xBAC2DE), 0); // mocha subtext1
|
||||
lv_obj_set_style_text_color(g_ctx.btn_stop, lv_color_hex(0x11111B), 0);
|
||||
lv_obj_t* lbl_stop = lv_label_create(g_ctx.btn_stop);
|
||||
lv_label_set_text(lbl_stop, LV_SYMBOL_STOP);
|
||||
lv_obj_center(lbl_stop);
|
||||
lv_obj_add_event_cb(g_ctx.btn_stop, on_stop_click, LV_EVENT_CLICKED, &g_ctx);
|
||||
|
||||
// Delete Button
|
||||
g_ctx.btn_delete = lv_btn_create(ctrl_box);
|
||||
lv_obj_set_size(g_ctx.btn_delete, 45, 32);
|
||||
lv_obj_set_style_radius(g_ctx.btn_delete, 8, 0);
|
||||
lv_obj_set_style_bg_color(g_ctx.btn_delete, lv_color_hex(0x74C7EC), 0);
|
||||
lv_obj_set_style_bg_color(g_ctx.btn_delete, lv_color_hex(0x74C7EC), 0); // mocha sapphire
|
||||
lv_obj_set_style_text_color(g_ctx.btn_delete, lv_color_hex(0x11111B), 0);
|
||||
lv_obj_t* lbl_del = lv_label_create(g_ctx.btn_delete);
|
||||
lv_label_set_text(lbl_del, LV_SYMBOL_TRASH);
|
||||
lv_obj_center(lbl_del);
|
||||
lv_obj_add_event_cb(g_ctx.btn_delete, on_delete_click, LV_EVENT_CLICKED, &g_ctx);
|
||||
|
||||
if (g_ctx.stream_dev == NULL) {
|
||||
lv_label_set_text(g_ctx.lbl_status, "ERROR: audio-stream missing");
|
||||
// Initialize list and control state
|
||||
if (g_ctx.i2s_dev == NULL) {
|
||||
lv_label_set_text(g_ctx.lbl_status, "ERROR: I2S 'i2s0' missing");
|
||||
lv_obj_add_state(g_ctx.btn_record, LV_STATE_DISABLED);
|
||||
lv_obj_add_state(g_ctx.btn_play_pause, LV_STATE_DISABLED);
|
||||
lv_obj_add_state(g_ctx.btn_stop, LV_STATE_DISABLED);
|
||||
@@ -728,17 +778,16 @@ static void onHideApp(AppHandle app, void* data) {
|
||||
g_ctx.state = STATE_IDLE;
|
||||
}
|
||||
|
||||
// Wait for task completion
|
||||
while (g_ctx.task_handle != NULL) {
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
}
|
||||
|
||||
if (g_ctx.input_handle) {
|
||||
audio_stream_close(g_ctx.input_handle);
|
||||
g_ctx.input_handle = NULL;
|
||||
}
|
||||
if (g_ctx.output_handle) {
|
||||
audio_stream_close(g_ctx.output_handle);
|
||||
g_ctx.output_handle = NULL;
|
||||
// Reset I2S to ensure DMA channel is stopped
|
||||
if (g_ctx.i2s_dev != NULL) {
|
||||
device_lock(g_ctx.i2s_dev);
|
||||
i2s_controller_reset(g_ctx.i2s_dev);
|
||||
device_unlock(g_ctx.i2s_dev);
|
||||
}
|
||||
|
||||
if (g_ctx.audio_buf != NULL) {
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
[manifest]
|
||||
version=0.1
|
||||
[target]
|
||||
sdk=0.8.0-dev
|
||||
platforms=esp32s3
|
||||
[app]
|
||||
id=one.tactility.voicerecorder
|
||||
name=Voice Recorder
|
||||
versionName=1.0.0
|
||||
versionCode=1
|
||||
@@ -0,0 +1,23 @@
|
||||
# Peanut-GB vendored library
|
||||
|
||||
Source: https://github.com/deltabeard/Peanut-GB
|
||||
File: peanut_gb.h (single-header emulator)
|
||||
License: MIT License - Copyright (c) 2018-2023 Mahyar Koshkouei
|
||||
Date Vendored: 2026-07-17 UTC
|
||||
Upstream: master branch latest as fetched 2026-07-17
|
||||
Commit URL: https://github.com/deltabeard/Peanut-GB/tree/master
|
||||
Size: ~4044 lines
|
||||
|
||||
MIT license text is preserved intact at top of peanut_gb.h header.
|
||||
SameBoy-derived portions: Copyright (c) 2015-2019 Lior Halphon, also MIT.
|
||||
|
||||
Usage:
|
||||
```c
|
||||
#define ENABLE_SOUND 0
|
||||
#define ENABLE_LCD 1
|
||||
#include "peanut_gb.h"
|
||||
```
|
||||
|
||||
No modifications applied — used as-is.
|
||||
|
||||
For Tactility GameBoy app, audio is disabled (ENABLE_SOUND 0) per prototype spec.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,7 +26,6 @@
|
||||
|
||||
#include <cstdint>
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/drivers/audio_stream.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "freertos/queue.h"
|
||||
@@ -278,8 +277,7 @@ private:
|
||||
// State
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
Device* streamDevice_ = nullptr;
|
||||
AudioStreamHandle streamHandle_ = nullptr;
|
||||
Device* i2sDevice_ = nullptr;
|
||||
TaskHandle_t task_ = nullptr;
|
||||
SemaphoreHandle_t stopSemaphore_ = nullptr; // Signaled when audio task exits
|
||||
QueueHandle_t msgQueue_ = nullptr;
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
#include "SfxEngine.h"
|
||||
#include "SfxDefinitions.h"
|
||||
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/drivers/audio_stream.h>
|
||||
#include <tactility/drivers/i2s_controller.h>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include "esp_log.h"
|
||||
@@ -425,7 +424,7 @@ void SfxEngine::audioTaskFunc(void* param) {
|
||||
size_t written;
|
||||
QueueMsg msg;
|
||||
|
||||
ESP_LOGI(TAG, "Audio task started (audio-stream)");
|
||||
ESP_LOGI(TAG, "Audio task started");
|
||||
|
||||
while (self->running_) {
|
||||
// Process queued messages (non-blocking)
|
||||
@@ -464,26 +463,19 @@ void SfxEngine::audioTaskFunc(void* param) {
|
||||
// Fill audio buffer (member buffer to avoid stack pressure)
|
||||
self->fillStereoBuffer(self->audioBuffer_, BUFFER_SAMPLES);
|
||||
|
||||
// Write via audio-stream (resampled to native codec rate, e.g. 44100)
|
||||
if (self->streamHandle_ == nullptr) {
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
continue;
|
||||
}
|
||||
error_t error = audio_stream_write(self->streamHandle_, self->audioBuffer_,
|
||||
sizeof(self->audioBuffer_), &written, pdMS_TO_TICKS(200));
|
||||
// Write to I2S
|
||||
error_t error = i2s_controller_write(self->i2sDevice_, self->audioBuffer_,
|
||||
sizeof(self->audioBuffer_), &written, pdMS_TO_TICKS(100));
|
||||
if (error != ERROR_NONE) {
|
||||
ESP_LOGE(TAG, "audio_stream_write error %d", error);
|
||||
ESP_LOGE(TAG, "I2S write error");
|
||||
self->running_ = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Flush silence to avoid pop
|
||||
if (self->streamHandle_ != nullptr) {
|
||||
memset(self->audioBuffer_, 0, sizeof(self->audioBuffer_));
|
||||
size_t dummy = 0;
|
||||
audio_stream_write(self->streamHandle_, self->audioBuffer_, sizeof(self->audioBuffer_), &dummy, pdMS_TO_TICKS(100));
|
||||
}
|
||||
// Flush silence
|
||||
memset(self->audioBuffer_, 0, sizeof(self->audioBuffer_));
|
||||
i2s_controller_write(self->i2sDevice_, self->audioBuffer_, sizeof(self->audioBuffer_), &written, pdMS_TO_TICKS(50));
|
||||
|
||||
ESP_LOGI(TAG, "Audio task exiting");
|
||||
|
||||
@@ -502,38 +494,33 @@ void SfxEngine::audioTaskFunc(void* param) {
|
||||
bool SfxEngine::start() {
|
||||
if (running_) return true;
|
||||
|
||||
// Find audio-stream device (new audio provision)
|
||||
streamDevice_ = nullptr;
|
||||
streamHandle_ = nullptr;
|
||||
// Find I2S device
|
||||
i2sDevice_ = nullptr;
|
||||
device_for_each_of_type(&I2S_CONTROLLER_TYPE, &i2sDevice_, [](Device* device, void* context) {
|
||||
if (!device_is_ready(device)) return true;
|
||||
Device** devicePtr = static_cast<Device**>(context);
|
||||
*devicePtr = device;
|
||||
return false;
|
||||
});
|
||||
|
||||
streamDevice_ = device_find_by_name("audio-stream");
|
||||
if (streamDevice_ == nullptr) {
|
||||
// Fallback: find first device of AUDIO_STREAM_TYPE
|
||||
device_for_each_of_type(&AUDIO_STREAM_TYPE, &streamDevice_, [](Device* device, void* context) {
|
||||
if (!device_is_ready(device)) return true;
|
||||
Device** devPtr = static_cast<Device**>(context);
|
||||
*devPtr = device;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
if (streamDevice_ == nullptr) {
|
||||
ESP_LOGW(TAG, "No audio-stream device found - audio provision missing");
|
||||
if (i2sDevice_ == nullptr) {
|
||||
ESP_LOGW(TAG, "No I2S device found");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Open output stream with resampling (app wants 16k stereo, codec native is 44100)
|
||||
struct AudioStreamConfig cfg = {
|
||||
// Configure I2S
|
||||
I2sConfig config = {
|
||||
.communication_format = I2S_FORMAT_STAND_I2S,
|
||||
.sample_rate = SAMPLE_RATE,
|
||||
.bits_per_sample = 16,
|
||||
.channels = 2
|
||||
.channel_left = 0,
|
||||
.channel_right = 0
|
||||
};
|
||||
|
||||
error_t error = audio_stream_open_output(streamDevice_, &cfg, &streamHandle_);
|
||||
error_t error = i2s_controller_set_config(i2sDevice_, &config);
|
||||
if (error != ERROR_NONE) {
|
||||
ESP_LOGE(TAG, "Failed to open audio-stream: %s (%d)", error_to_string(error), error);
|
||||
streamDevice_ = nullptr;
|
||||
streamHandle_ = nullptr;
|
||||
ESP_LOGE(TAG, "Failed to configure I2S: %s", error_to_string(error));
|
||||
i2sDevice_ = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -541,13 +528,12 @@ bool SfxEngine::start() {
|
||||
msgQueue_ = xQueueCreate(8, sizeof(QueueMsg));
|
||||
if (msgQueue_ == nullptr) {
|
||||
ESP_LOGE(TAG, "Failed to create message queue");
|
||||
audio_stream_close(streamHandle_);
|
||||
streamHandle_ = nullptr;
|
||||
streamDevice_ = nullptr;
|
||||
i2s_controller_reset(i2sDevice_);
|
||||
i2sDevice_ = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Start audio task (needs slightly larger stack for audio_stream write path)
|
||||
// Start audio task
|
||||
running_ = true;
|
||||
BaseType_t result = xTaskCreate(audioTaskFunc, "sfxeng", 4096, this, 5, &task_);
|
||||
if (result != pdPASS) {
|
||||
@@ -555,13 +541,12 @@ bool SfxEngine::start() {
|
||||
running_ = false;
|
||||
vQueueDelete(msgQueue_);
|
||||
msgQueue_ = nullptr;
|
||||
audio_stream_close(streamHandle_);
|
||||
streamHandle_ = nullptr;
|
||||
streamDevice_ = nullptr;
|
||||
i2s_controller_reset(i2sDevice_);
|
||||
i2sDevice_ = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "SfxEngine started via audio-stream (voices=%d, sampleRate=%d -> resampled to native)", NUM_VOICES, SAMPLE_RATE);
|
||||
ESP_LOGI(TAG, "SfxEngine started (voices=%d, sampleRate=%d)", NUM_VOICES, SAMPLE_RATE);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -590,13 +575,11 @@ void SfxEngine::stop() {
|
||||
msgQueue_ = nullptr;
|
||||
}
|
||||
|
||||
if (streamHandle_ != nullptr) {
|
||||
audio_stream_close(streamHandle_);
|
||||
streamHandle_ = nullptr;
|
||||
if (i2sDevice_ != nullptr) {
|
||||
i2s_controller_reset(i2sDevice_);
|
||||
i2sDevice_ = nullptr;
|
||||
}
|
||||
|
||||
streamDevice_ = nullptr;
|
||||
|
||||
ESP_LOGI(TAG, "SfxEngine stopped");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user