Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a1fe5dda6 | |||
| d06f8ddbb5 | |||
| a2bb8e5132 | |||
| 9ee0599bec | |||
| c3689e82cc | |||
| 4c71c77bf6 | |||
| 0595e149d4 | |||
| 995297314d | |||
| 349034b8ba | |||
| 3085bac5f1 | |||
| b03cb2b244 | |||
| 3a2e6f3f11 | |||
| 9def43b4ed | |||
| c258aebca1 | |||
| 4a153099f9 | |||
| aebee50d25 | |||
| 7c3c4ad2fe | |||
| 2a45c98fb3 | |||
| 25b966a340 | |||
| b2e9046438 | |||
| f07d347fd9 | |||
| 8f46e03070 | |||
| 7121009d0d | |||
| 8721a653e7 | |||
| 694b68de1a | |||
| 71bf2631f4 | |||
| 4ab2377970 | |||
| 8a0f9ef4e4 |
@@ -0,0 +1,21 @@
|
|||||||
|
name: Publish Apps
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
sdk_version:
|
||||||
|
description: The SDK version that determines the path on the CDN
|
||||||
|
required: true
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: 'composite'
|
||||||
|
steps:
|
||||||
|
- name: 'Download cdn-files'
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: 'cdn-files'
|
||||||
|
path: cdn_files
|
||||||
|
- name: 'Install boto3'
|
||||||
|
shell: bash
|
||||||
|
run: pip install boto3
|
||||||
|
- name: 'Upload files'
|
||||||
|
shell: bash
|
||||||
|
run: python Buildscripts/CDN/upload-app-files.py cdn_files ${{ inputs.sdk_version }} ${{ env.CDN_ID }} ${{ env.CDN_TOKEN_NAME }} ${{ env.CDN_TOKEN_VALUE }}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
name: Release Apps
|
name: Release Apps
|
||||||
|
|
||||||
|
outputs:
|
||||||
|
sdk_version:
|
||||||
|
description: 'Common SDK version shared by all bundled apps'
|
||||||
|
value: ${{ steps.release.outputs.sdk_version }}
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: 'composite'
|
using: 'composite'
|
||||||
steps:
|
steps:
|
||||||
@@ -11,8 +16,11 @@ runs:
|
|||||||
run: rsync -av downloaded_apps/*/*.app cdn_files/
|
run: rsync -av downloaded_apps/*/*.app cdn_files/
|
||||||
shell: bash
|
shell: bash
|
||||||
- name: 'Create CDN release files'
|
- name: 'Create CDN release files'
|
||||||
run: python release.py cdn_files/
|
id: release
|
||||||
shell: bash
|
shell: bash
|
||||||
|
run: |
|
||||||
|
python release.py cdn_files/
|
||||||
|
echo "sdk_version=$(cat sdk_version.txt)" >> "$GITHUB_OUTPUT"
|
||||||
- name: 'Upload Artifact'
|
- name: 'Upload Artifact'
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ jobs:
|
|||||||
Build:
|
Build:
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
app_name: [Brainfuck, Breakout, BookPlayer, Calculator, Diceware, EpubReader, GPIO, GraphicsDemo, HelloWorld, M5UnitTest, Magic8Ball, MediaKeys, MystifyDemo, SerialConsole, Snake, TamaTac, TodoList, TwoEleven]
|
app_name: [AudioTest, BibleVerse, BookPlayer, Brainfuck, Breakout, Calculator, Diceware, EpubReader, EspNowBridge, GPIO, GameBoy, GraphicsDemo, HelloWorld, M5UnitTest, Magic8Ball, McpScreen, MediaKeys, Mp3Player, MystifyDemo, PocketDungeon, ReynaBot, RobotArm, SerialConsole, Snake, TamaTac, TodoList, TwoEleven, VoiceRecorder]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: "Build"
|
- name: "Build"
|
||||||
uses: ./.github/actions/build-app
|
uses: ./.github/actions/build-app
|
||||||
with:
|
with:
|
||||||
@@ -23,7 +25,26 @@ jobs:
|
|||||||
Bundle:
|
Bundle:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [Build]
|
needs: [Build]
|
||||||
|
outputs:
|
||||||
|
sdk_version: ${{ steps.release.outputs.sdk_version }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: "Build"
|
- name: "Build"
|
||||||
|
id: release
|
||||||
uses: ./.github/actions/release-apps
|
uses: ./.github/actions/release-apps
|
||||||
|
PublishApps:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [Bundle]
|
||||||
|
if: (github.event_name == 'push' && github.ref == 'refs/heads/main')
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: "Publish Apps"
|
||||||
|
env:
|
||||||
|
CDN_ID: ${{ secrets.CDN_ID }}
|
||||||
|
CDN_TOKEN_NAME: ${{ secrets.CDN_TOKEN_NAME }}
|
||||||
|
CDN_TOKEN_VALUE: ${{ secrets.CDN_TOKEN_VALUE }}
|
||||||
|
uses: ./.github/actions/publish-apps
|
||||||
|
with:
|
||||||
|
sdk_version: ${{ needs.Bundle.outputs.sdk_version }}
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32s3
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.audiotest
|
||||||
platforms=esp32s3
|
app.version.name=0.1.0
|
||||||
[app]
|
app.version.code=1
|
||||||
id=one.tactility.audiotest
|
app.name=Audio Test
|
||||||
versionName=0.1.0
|
|
||||||
versionCode=1
|
|
||||||
name=Audio Test
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ LV_FONT_DECLARE(georgia_regular_24)
|
|||||||
#define MAX_BIBLE_BOOKS 66
|
#define MAX_BIBLE_BOOKS 66
|
||||||
#define MAX_BOOK_NAME 40
|
#define MAX_BOOK_NAME 40
|
||||||
#define MAX_VERSE_TEXT 2048
|
#define MAX_VERSE_TEXT 2048
|
||||||
#define MAX_PATH 320
|
#define MAX_PATH 128
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
uint32_t offset;
|
uint32_t offset;
|
||||||
@@ -239,7 +239,7 @@ static bool parse_books_json(AppCtx* ctx, const char* json_str) {
|
|||||||
const char* obj_end = strchr(obj_start, '}');
|
const char* obj_end = strchr(obj_start, '}');
|
||||||
if (!obj_end) break;
|
if (!obj_end) break;
|
||||||
// parse fields within obj_start..obj_end
|
// parse fields within obj_start..obj_end
|
||||||
char temp[512];
|
char temp[256];
|
||||||
size_t len = (size_t)(obj_end - obj_start + 1);
|
size_t len = (size_t)(obj_end - obj_start + 1);
|
||||||
if (len >= sizeof(temp)) len = sizeof(temp)-1;
|
if (len >= sizeof(temp)) len = sizeof(temp)-1;
|
||||||
memcpy(temp, obj_start, len);
|
memcpy(temp, obj_start, len);
|
||||||
@@ -672,27 +672,27 @@ static void show_current_verse(AppCtx* ctx) {
|
|||||||
const char* text_ptr = "(empty)";
|
const char* text_ptr = "(empty)";
|
||||||
if (off < ctx->book_bin_size) text_ptr = (const char*)(ctx->book_bin + off);
|
if (off < ctx->book_bin_size) text_ptr = (const char*)(ctx->book_bin + off);
|
||||||
|
|
||||||
char verse_buf[MAX_VERSE_TEXT];
|
// Fix stack overflow: verse_buf was 2048 bytes on stack, causing gui task (4096) overflow
|
||||||
strncpy(verse_buf, text_ptr, sizeof(verse_buf)-1);
|
// Use heap allocation
|
||||||
verse_buf[sizeof(verse_buf)-1]='\0';
|
char* verse_buf = (char*)malloc(MAX_VERSE_TEXT);
|
||||||
|
if (!verse_buf) {
|
||||||
|
ESP_LOGE(TAG, "Failed to malloc verse_buf");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
strncpy(verse_buf, text_ptr, MAX_VERSE_TEXT-1);
|
||||||
|
verse_buf[MAX_VERSE_TEXT-1]='\0';
|
||||||
|
|
||||||
char ref_buf[96];
|
char ref_buf[64];
|
||||||
snprintf(ref_buf, sizeof(ref_buf), "%s %d:%d", ctx->books[ctx->cur_book_idx].bname, vi->cnum, vi->vnum);
|
snprintf(ref_buf, sizeof(ref_buf), "%s %d:%d", ctx->books[ctx->cur_book_idx].bname, vi->cnum, vi->vnum);
|
||||||
|
|
||||||
// Editorial reference style like your image: "PSALMS 23:1" with tracking
|
char ref_pretty[80];
|
||||||
char ref_pretty[108];
|
char bname_upper[40];
|
||||||
// Use uppercase for book? Keep title for now but uppercase style for editorial match
|
|
||||||
// We'll format as "— PSALMS 23:1 —" feel? For dark mode keep minimal like ref image
|
|
||||||
// Reference image shows lines flanking citation - we do with letterspacing + dim
|
|
||||||
// For now: "PSALMS 23:1" style - uppercase via runtime? We'll uppercase book name
|
|
||||||
char bname_upper[48];
|
|
||||||
strncpy(bname_upper, ctx->books[ctx->cur_book_idx].bname, sizeof(bname_upper)-1);
|
strncpy(bname_upper, ctx->books[ctx->cur_book_idx].bname, sizeof(bname_upper)-1);
|
||||||
bname_upper[sizeof(bname_upper)-1]='\0';
|
bname_upper[sizeof(bname_upper)-1]='\0';
|
||||||
for (char* p=bname_upper; *p; ++p) *p = toupper((unsigned char)*p);
|
for (char* p=bname_upper; *p; ++p) *p = toupper((unsigned char)*p);
|
||||||
snprintf(ref_pretty, sizeof(ref_pretty), "%s %d:%d", bname_upper, vi->cnum, vi->vnum);
|
snprintf(ref_pretty, sizeof(ref_pretty), "%s %d:%d", bname_upper, vi->cnum, vi->vnum);
|
||||||
|
|
||||||
if (ctx->lbl_verse) {
|
if (ctx->lbl_verse) {
|
||||||
// start faded for transition
|
|
||||||
lv_obj_set_style_text_opa(ctx->lbl_verse, 60, 0);
|
lv_obj_set_style_text_opa(ctx->lbl_verse, 60, 0);
|
||||||
lv_label_set_text(ctx->lbl_verse, verse_buf);
|
lv_label_set_text(ctx->lbl_verse, verse_buf);
|
||||||
apply_verse_scaling(ctx, verse_buf);
|
apply_verse_scaling(ctx, verse_buf);
|
||||||
@@ -741,6 +741,7 @@ static void show_current_verse(AppCtx* ctx) {
|
|||||||
if (ctx->cur_global >= ctx->total_verses-1) lv_obj_add_state(ctx->btn_next, LV_STATE_DISABLED);
|
if (ctx->cur_global >= ctx->total_verses-1) lv_obj_add_state(ctx->btn_next, LV_STATE_DISABLED);
|
||||||
else lv_obj_clear_state(ctx->btn_next, LV_STATE_DISABLED);
|
else lv_obj_clear_state(ctx->btn_next, LV_STATE_DISABLED);
|
||||||
}
|
}
|
||||||
|
free(verse_buf);
|
||||||
save_progress(ctx);
|
save_progress(ctx);
|
||||||
verse_fade_in(ctx);
|
verse_fade_in(ctx);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32s3,esp32p4
|
||||||
sdk=0.8.0-dev
|
app.id=one.tactility.bibleverse
|
||||||
platforms=esp32s3,esp32p4
|
app.version.name=0.1.0
|
||||||
[app]
|
app.version.code=2
|
||||||
id=one.tactility.bibleverse
|
app.name=Bible Verse
|
||||||
versionName=0.1.0
|
app.description=Single verse at a time, advances each minute. Tap to show controls.
|
||||||
versionCode=1
|
|
||||||
name=Bible Verse
|
|
||||||
description=Single verse at a time, advances each minute. Tap to show controls.
|
|
||||||
|
|||||||
+154
-179
@@ -4,7 +4,7 @@
|
|||||||
#include <tt_app_alertdialog.h>
|
#include <tt_app_alertdialog.h>
|
||||||
|
|
||||||
#include <tactility/device.h>
|
#include <tactility/device.h>
|
||||||
#include <tactility/drivers/i2s_controller.h>
|
#include <tactility/drivers/audio_stream.h>
|
||||||
|
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
@@ -45,14 +45,15 @@ typedef struct {
|
|||||||
} BookMetadata;
|
} BookMetadata;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
struct Device* i2s_dev;
|
struct Device* stream_dev;
|
||||||
|
AudioStreamHandle stream_handle;
|
||||||
PlaybackState state;
|
PlaybackState state;
|
||||||
int volume;
|
int volume;
|
||||||
|
|
||||||
// Book picker list data
|
// Book picker list data
|
||||||
BookMetadata books[MAX_BOOKS];
|
BookMetadata books[MAX_BOOKS];
|
||||||
int book_count;
|
int book_count;
|
||||||
|
|
||||||
// Currently loaded book details
|
// Currently loaded book details
|
||||||
char current_book_slug[MAX_PATH];
|
char current_book_slug[MAX_PATH];
|
||||||
cJSON* manifest_root;
|
cJSON* manifest_root;
|
||||||
@@ -60,12 +61,12 @@ typedef struct {
|
|||||||
int page_count;
|
int page_count;
|
||||||
int current_page;
|
int current_page;
|
||||||
int last_pct;
|
int last_pct;
|
||||||
|
|
||||||
// UI elements
|
// UI elements
|
||||||
AppHandle app;
|
AppHandle app;
|
||||||
lv_obj_t* picker_wrapper;
|
lv_obj_t* picker_wrapper;
|
||||||
lv_obj_t* lst_books;
|
lv_obj_t* lst_books;
|
||||||
|
|
||||||
lv_obj_t* player_wrapper;
|
lv_obj_t* player_wrapper;
|
||||||
lv_obj_t* header_bar;
|
lv_obj_t* header_bar;
|
||||||
lv_obj_t* ctrl_bar;
|
lv_obj_t* ctrl_bar;
|
||||||
@@ -76,29 +77,29 @@ typedef struct {
|
|||||||
lv_obj_t* btn_play_pause;
|
lv_obj_t* btn_play_pause;
|
||||||
lv_obj_t* btn_prev;
|
lv_obj_t* btn_prev;
|
||||||
lv_obj_t* btn_next;
|
lv_obj_t* btn_next;
|
||||||
|
|
||||||
// Audio State
|
// Audio State
|
||||||
char current_audio_path[512];
|
char current_audio_path[512];
|
||||||
uint8_t* audio_buf; // Shared MP3 input and WAV buffer
|
uint8_t* audio_buf; // Shared MP3 input and WAV buffer
|
||||||
mp3d_sample_t* pcm_buf; // MP3 decoded pcm buffer
|
mp3d_sample_t* pcm_buf; // MP3 decoded pcm buffer
|
||||||
|
|
||||||
TaskHandle_t playback_task_handle;
|
TaskHandle_t playback_task_handle;
|
||||||
} AppCtx;
|
} AppCtx;
|
||||||
|
|
||||||
typedef struct __attribute__((packed)) {
|
typedef struct __attribute__((packed)) {
|
||||||
char riff[4]; // "RIFF"
|
char riff[4];
|
||||||
uint32_t overall_size; // file size - 8
|
uint32_t overall_size;
|
||||||
char wave[4]; // "WAVE"
|
char wave[4];
|
||||||
char fmt_chunk_marker[4]; // "fmt "
|
char fmt_chunk_marker[4];
|
||||||
uint32_t length_of_fmt; // 16 for PCM
|
uint32_t length_of_fmt;
|
||||||
uint16_t format_type; // 1 for PCM
|
uint16_t format_type;
|
||||||
uint16_t channels; // 1 for mono
|
uint16_t channels;
|
||||||
uint32_t sample_rate; // 16000
|
uint32_t sample_rate;
|
||||||
uint32_t byterate; // sample_rate * channels * (bits_per_sample / 8)
|
uint32_t byterate;
|
||||||
uint16_t block_align; // channels * (bits_per_sample / 8)
|
uint16_t block_align;
|
||||||
uint16_t bits_per_sample; // 16
|
uint16_t bits_per_sample;
|
||||||
char data_chunk_header[4]; // "data"
|
char data_chunk_header[4];
|
||||||
uint32_t data_size; // data size in bytes
|
uint32_t data_size;
|
||||||
} WavHeader;
|
} WavHeader;
|
||||||
|
|
||||||
static AppCtx g_ctx;
|
static AppCtx g_ctx;
|
||||||
@@ -113,19 +114,60 @@ static void play_mp3(AppCtx* ctx);
|
|||||||
static void play_wav(AppCtx* ctx);
|
static void play_wav(AppCtx* ctx);
|
||||||
static void scan_books(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 ─── */
|
/* ─── UI Helper to update status labels & button states ─── */
|
||||||
static void update_ui(AppCtx* ctx) {
|
static void update_ui(AppCtx* ctx) {
|
||||||
if (!ctx->btn_play_pause) return;
|
if (!ctx->btn_play_pause) return;
|
||||||
|
|
||||||
lv_obj_t* lbl_play = lv_obj_get_child(ctx->btn_play_pause, 0);
|
lv_obj_t* lbl_play = lv_obj_get_child(ctx->btn_play_pause, 0);
|
||||||
|
|
||||||
// Prev button state
|
// Prev button state
|
||||||
if (ctx->current_page <= 0) {
|
if (ctx->current_page <= 0) {
|
||||||
lv_obj_add_state(ctx->btn_prev, LV_STATE_DISABLED);
|
lv_obj_add_state(ctx->btn_prev, LV_STATE_DISABLED);
|
||||||
} else {
|
} else {
|
||||||
lv_obj_clear_state(ctx->btn_prev, LV_STATE_DISABLED);
|
lv_obj_clear_state(ctx->btn_prev, LV_STATE_DISABLED);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Next button state
|
// Next button state
|
||||||
if (ctx->current_page >= ctx->page_count - 1) {
|
if (ctx->current_page >= ctx->page_count - 1) {
|
||||||
lv_obj_add_state(ctx->btn_next, LV_STATE_DISABLED);
|
lv_obj_add_state(ctx->btn_next, LV_STATE_DISABLED);
|
||||||
@@ -162,13 +204,13 @@ static char* read_full_file(const char* filepath) {
|
|||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
fseek(file, 0, SEEK_SET);
|
fseek(file, 0, SEEK_SET);
|
||||||
|
|
||||||
char* buf = malloc(size + 1);
|
char* buf = malloc(size + 1);
|
||||||
if (!buf) {
|
if (!buf) {
|
||||||
fclose(file);
|
fclose(file);
|
||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
|
|
||||||
size_t read_bytes = fread(buf, 1, size, file);
|
size_t read_bytes = fread(buf, 1, size, file);
|
||||||
buf[read_bytes] = '\0';
|
buf[read_bytes] = '\0';
|
||||||
fclose(file);
|
fclose(file);
|
||||||
@@ -185,7 +227,6 @@ static void on_auto_advance_timer(lv_timer_t* timer) {
|
|||||||
|
|
||||||
static void handle_audio_finished(AppCtx* ctx) {
|
static void handle_audio_finished(AppCtx* ctx) {
|
||||||
if (ctx->current_page + 1 < ctx->page_count) {
|
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_t* timer = lv_timer_create(on_auto_advance_timer, 0, ctx);
|
||||||
lv_timer_set_repeat_count(timer, 1);
|
lv_timer_set_repeat_count(timer, 1);
|
||||||
} else {
|
} else {
|
||||||
@@ -208,7 +249,6 @@ static void wait_for_playback_task_to_exit(AppCtx* ctx) {
|
|||||||
|
|
||||||
/* ─── Load Page Content (Image, Caption, Audio path) ─── */
|
/* ─── Load Page Content (Image, Caption, Audio path) ─── */
|
||||||
static void load_page(AppCtx* ctx, int page_index, bool start_audio) {
|
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);
|
wait_for_playback_task_to_exit(ctx);
|
||||||
|
|
||||||
if (page_index < 0 || page_index >= ctx->page_count) return;
|
if (page_index < 0 || page_index >= ctx->page_count) return;
|
||||||
@@ -225,7 +265,7 @@ static void load_page(AppCtx* ctx, int page_index, bool start_audio) {
|
|||||||
if (img_item && img_item->valuestring) {
|
if (img_item && img_item->valuestring) {
|
||||||
char img_path[512];
|
char img_path[512];
|
||||||
snprintf(img_path, sizeof(img_path), "/sdcard/books/%s/%s", ctx->current_book_slug, img_item->valuestring);
|
snprintf(img_path, sizeof(img_path), "/sdcard/books/%s/%s", ctx->current_book_slug, img_item->valuestring);
|
||||||
|
|
||||||
FILE* img_file = fopen(img_path, "rb");
|
FILE* img_file = fopen(img_path, "rb");
|
||||||
if (img_file) {
|
if (img_file) {
|
||||||
fclose(img_file);
|
fclose(img_file);
|
||||||
@@ -238,15 +278,13 @@ static void load_page(AppCtx* ctx, int page_index, bool start_audio) {
|
|||||||
lv_image_set_src(ctx->img_page, lv_img_path);
|
lv_image_set_src(ctx->img_page, lv_img_path);
|
||||||
} else {
|
} else {
|
||||||
ESP_LOGW(TAG, "Image file not found: %s", img_path);
|
ESP_LOGW(TAG, "Image file not found: %s", img_path);
|
||||||
lv_image_set_src(ctx->img_page, LV_SYMBOL_IMAGE); // Show default fallback icon
|
lv_image_set_src(ctx->img_page, LV_SYMBOL_IMAGE);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
lv_image_set_src(ctx->img_page, LV_SYMBOL_IMAGE);
|
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];
|
char ind_buf[32];
|
||||||
snprintf(ind_buf, sizeof(ind_buf), "%d / %d", page_index + 1, ctx->page_count);
|
snprintf(ind_buf, sizeof(ind_buf), "%d / %d", page_index + 1, ctx->page_count);
|
||||||
lv_label_set_text(ctx->lbl_indicator, ind_buf);
|
lv_label_set_text(ctx->lbl_indicator, ind_buf);
|
||||||
@@ -258,18 +296,16 @@ static void load_page(AppCtx* ctx, int page_index, bool start_audio) {
|
|||||||
ctx->current_audio_path[0] = '\0';
|
ctx->current_audio_path[0] = '\0';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update UI states (like Prev/Next buttons)
|
|
||||||
update_ui(ctx);
|
update_ui(ctx);
|
||||||
|
|
||||||
// Start playback if requested and valid
|
// Start playback if requested and valid
|
||||||
if (start_audio && ctx->current_audio_path[0] != '\0') {
|
if (start_audio && ctx->current_audio_path[0] != '\0') {
|
||||||
// Verify audio file exists
|
|
||||||
FILE* audio_file = fopen(ctx->current_audio_path, "rb");
|
FILE* audio_file = fopen(ctx->current_audio_path, "rb");
|
||||||
if (audio_file) {
|
if (audio_file) {
|
||||||
fclose(audio_file);
|
fclose(audio_file);
|
||||||
ctx->state = STATE_PLAYING;
|
ctx->state = STATE_PLAYING;
|
||||||
update_ui(ctx);
|
update_ui(ctx);
|
||||||
xTaskCreate(audio_playback_task, "audio_play", 4096, ctx, 5, &ctx->playback_task_handle);
|
xTaskCreate(audio_playback_task, "audio_play", 8192, ctx, 5, &ctx->playback_task_handle);
|
||||||
} else {
|
} else {
|
||||||
ESP_LOGE(TAG, "Audio file not found: %s", ctx->current_audio_path);
|
ESP_LOGE(TAG, "Audio file not found: %s", ctx->current_audio_path);
|
||||||
const char* buttons[] = {"OK"};
|
const char* buttons[] = {"OK"};
|
||||||
@@ -300,7 +336,7 @@ static void return_to_picker(AppCtx* ctx) {
|
|||||||
/* ─── Task Selection (MP3 or WAV) ─── */
|
/* ─── Task Selection (MP3 or WAV) ─── */
|
||||||
static void audio_playback_task(void* arg) {
|
static void audio_playback_task(void* arg) {
|
||||||
AppCtx* ctx = (AppCtx*)arg;
|
AppCtx* ctx = (AppCtx*)arg;
|
||||||
|
|
||||||
size_t len = strlen(ctx->current_audio_path);
|
size_t len = strlen(ctx->current_audio_path);
|
||||||
bool is_wav = false;
|
bool is_wav = false;
|
||||||
if (len > 4 && strcasecmp(ctx->current_audio_path + len - 4, ".wav") == 0) {
|
if (len > 4 && strcasecmp(ctx->current_audio_path + len - 4, ".wav") == 0) {
|
||||||
@@ -314,9 +350,8 @@ static void audio_playback_task(void* arg) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── MP3 Decoder Routine ─── */
|
/* ─── MP3 Decoder Routine (audio-stream) ─── */
|
||||||
static void play_mp3(AppCtx* ctx) {
|
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));
|
vTaskDelay(pdMS_TO_TICKS(400));
|
||||||
|
|
||||||
FILE* file = fopen(ctx->current_audio_path, "rb");
|
FILE* file = fopen(ctx->current_audio_path, "rb");
|
||||||
@@ -354,14 +389,12 @@ static void play_mp3(AppCtx* ctx) {
|
|||||||
int sample_rate = 0;
|
int sample_rate = 0;
|
||||||
int channels = 0;
|
int channels = 0;
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Starting MP3 playback: %s (%d bytes)", ctx->current_audio_path, file_size);
|
ESP_LOGI(TAG, "Starting MP3 playback via audio-stream: %s (%d bytes)", ctx->current_audio_path, file_size);
|
||||||
|
|
||||||
while (ctx->state != STATE_IDLE) {
|
while (ctx->state != STATE_IDLE) {
|
||||||
if (ctx->state == STATE_PAUSED) {
|
if (ctx->state == STATE_PAUSED) {
|
||||||
if (sample_rate != 0) {
|
if (ctx->stream_handle) {
|
||||||
device_lock(ctx->i2s_dev);
|
close_stream_if_open(ctx);
|
||||||
i2s_controller_reset(ctx->i2s_dev);
|
|
||||||
device_unlock(ctx->i2s_dev);
|
|
||||||
sample_rate = 0;
|
sample_rate = 0;
|
||||||
channels = 0;
|
channels = 0;
|
||||||
}
|
}
|
||||||
@@ -390,7 +423,6 @@ static void play_mp3(AppCtx* ctx) {
|
|||||||
|
|
||||||
if (info.frame_bytes <= 0) {
|
if (info.frame_bytes <= 0) {
|
||||||
if (eof) break;
|
if (eof) break;
|
||||||
// Resync
|
|
||||||
memmove(ctx->audio_buf, ctx->audio_buf + 1, --buffered_bytes);
|
memmove(ctx->audio_buf, ctx->audio_buf + 1, --buffered_bytes);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -400,22 +432,10 @@ static void play_mp3(AppCtx* ctx) {
|
|||||||
memmove(ctx->audio_buf, ctx->audio_buf + consumed, buffered_bytes);
|
memmove(ctx->audio_buf, ctx->audio_buf + consumed, buffered_bytes);
|
||||||
|
|
||||||
if (samples > 0) {
|
if (samples > 0) {
|
||||||
// Configure I2S
|
// Open/reopen stream on format change
|
||||||
if (sample_rate != info.hz || channels != info.channels) {
|
if (sample_rate != info.hz || channels != info.channels) {
|
||||||
struct I2sConfig config = {
|
if (!open_output_stream(ctx, (uint32_t)info.hz, (uint8_t)info.channels, 16)) {
|
||||||
.communication_format = I2S_FORMAT_STAND_I2S,
|
ESP_LOGE(TAG, "Failed to open audio stream for MP3: %d Hz %d ch", info.hz, info.channels);
|
||||||
.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;
|
break;
|
||||||
}
|
}
|
||||||
sample_rate = info.hz;
|
sample_rate = info.hz;
|
||||||
@@ -431,15 +451,15 @@ static void play_mp3(AppCtx* ctx) {
|
|||||||
samples_ptr[i] = (int16_t)scaled;
|
samples_ptr[i] = (int16_t)scaled;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write PCM to I2S
|
// Write via audio_stream (resampled to native 44100 internally)
|
||||||
size_t offset = 0;
|
size_t offset = 0;
|
||||||
size_t data_size = sample_count * sizeof(int16_t);
|
size_t data_size = sample_count * sizeof(int16_t);
|
||||||
bool write_err = false;
|
bool write_err = false;
|
||||||
while (offset < data_size && ctx->state == STATE_PLAYING) {
|
while (offset < data_size && ctx->state == STATE_PLAYING) {
|
||||||
size_t written = 0;
|
size_t written = 0;
|
||||||
error_t err = i2s_controller_write(ctx->i2s_dev, (uint8_t*)ctx->pcm_buf + offset, data_size - offset, &written, pdMS_TO_TICKS(250));
|
error_t err = audio_stream_write(ctx->stream_handle, (uint8_t*)ctx->pcm_buf + offset, data_size - offset, &written, pdMS_TO_TICKS(1000));
|
||||||
if (err != ERROR_NONE || written == 0) {
|
if (err != ERROR_NONE || written == 0) {
|
||||||
ESP_LOGE(TAG, "I2S write error: %d", err);
|
ESP_LOGE(TAG, "audio_stream_write error: %d", err);
|
||||||
write_err = true;
|
write_err = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -465,12 +485,7 @@ static void play_mp3(AppCtx* ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fclose(file);
|
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);
|
bool stopped_externally = (ctx->state == STATE_IDLE);
|
||||||
|
|
||||||
@@ -489,9 +504,8 @@ static void play_mp3(AppCtx* ctx) {
|
|||||||
vTaskDelete(NULL);
|
vTaskDelete(NULL);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── WAV Decoder Routine ─── */
|
/* ─── WAV Decoder Routine (audio-stream) ─── */
|
||||||
static void play_wav(AppCtx* ctx) {
|
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));
|
vTaskDelay(pdMS_TO_TICKS(400));
|
||||||
|
|
||||||
FILE* file = fopen(ctx->current_audio_path, "rb");
|
FILE* file = fopen(ctx->current_audio_path, "rb");
|
||||||
@@ -538,49 +552,36 @@ static void play_wav(AppCtx* ctx) {
|
|||||||
fseek(file, sizeof(WavHeader), SEEK_SET);
|
fseek(file, sizeof(WavHeader), SEEK_SET);
|
||||||
}
|
}
|
||||||
|
|
||||||
struct I2sConfig config = {
|
if (!open_output_stream(ctx, header.sample_rate, header.channels, header.bits_per_sample)) {
|
||||||
.communication_format = I2S_FORMAT_STAND_I2S,
|
ESP_LOGE(TAG, "Failed to open audio stream for WAV: %u Hz %u ch", (unsigned)header.sample_rate, header.channels);
|
||||||
.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);
|
fclose(file);
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
ctx->state = STATE_IDLE;
|
||||||
|
update_ui(ctx);
|
||||||
|
tt_lvgl_unlock();
|
||||||
ctx->playback_task_handle = NULL;
|
ctx->playback_task_handle = NULL;
|
||||||
vTaskDelete(NULL);
|
vTaskDelete(NULL);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Starting WAV playback: %s (%u Hz, %u channels)", ctx->current_audio_path, (unsigned int)header.sample_rate, (unsigned int)header.channels);
|
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);
|
||||||
|
|
||||||
size_t total_played = 0;
|
size_t total_played = 0;
|
||||||
bool i2s_configured = true;
|
bool stream_open = true;
|
||||||
|
|
||||||
while (total_played < data_size && ctx->state != STATE_IDLE) {
|
while (total_played < data_size && ctx->state != STATE_IDLE) {
|
||||||
if (ctx->state == STATE_PAUSED) {
|
if (ctx->state == STATE_PAUSED) {
|
||||||
if (i2s_configured) {
|
if (stream_open) {
|
||||||
device_lock(ctx->i2s_dev);
|
close_stream_if_open(ctx);
|
||||||
i2s_controller_reset(ctx->i2s_dev);
|
stream_open = false;
|
||||||
device_unlock(ctx->i2s_dev);
|
|
||||||
i2s_configured = false;
|
|
||||||
}
|
}
|
||||||
vTaskDelay(pdMS_TO_TICKS(50));
|
vTaskDelay(pdMS_TO_TICKS(50));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!i2s_configured) {
|
if (!stream_open) {
|
||||||
device_lock(ctx->i2s_dev);
|
if (!open_output_stream(ctx, header.sample_rate, header.channels, header.bits_per_sample)) break;
|
||||||
err = i2s_controller_set_config(ctx->i2s_dev, &config);
|
stream_open = true;
|
||||||
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;
|
size_t to_read = (data_size - total_played < MP3_INPUT_BUFFER_SIZE) ? (data_size - total_played) : MP3_INPUT_BUFFER_SIZE;
|
||||||
@@ -596,14 +597,14 @@ static void play_wav(AppCtx* ctx) {
|
|||||||
samples_ptr[i] = (int16_t)scaled;
|
samples_ptr[i] = (int16_t)scaled;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Play to I2S
|
// Write via audio_stream
|
||||||
size_t offset = 0;
|
size_t offset = 0;
|
||||||
bool write_err = false;
|
bool write_err = false;
|
||||||
while (offset < read_bytes && ctx->state == STATE_PLAYING) {
|
while (offset < read_bytes && ctx->state == STATE_PLAYING) {
|
||||||
size_t written = 0;
|
size_t written = 0;
|
||||||
err = i2s_controller_write(ctx->i2s_dev, ctx->audio_buf + offset, read_bytes - offset, &written, pdMS_TO_TICKS(100));
|
error_t err = audio_stream_write(ctx->stream_handle, ctx->audio_buf + offset, read_bytes - offset, &written, pdMS_TO_TICKS(500));
|
||||||
if (err != ERROR_NONE || written == 0) {
|
if (err != ERROR_NONE || written == 0) {
|
||||||
ESP_LOGE(TAG, "I2S WAV write error: %d", err);
|
ESP_LOGE(TAG, "audio_stream WAV write error: %d", err);
|
||||||
write_err = true;
|
write_err = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -614,7 +615,6 @@ static void play_wav(AppCtx* ctx) {
|
|||||||
|
|
||||||
total_played += read_bytes;
|
total_played += read_bytes;
|
||||||
|
|
||||||
// Update progress bar
|
|
||||||
int pct = (int)((total_played * 100) / data_size);
|
int pct = (int)((total_played * 100) / data_size);
|
||||||
if (pct < 0) pct = 0;
|
if (pct < 0) pct = 0;
|
||||||
if (pct > 100) pct = 100;
|
if (pct > 100) pct = 100;
|
||||||
@@ -629,12 +629,7 @@ static void play_wav(AppCtx* ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fclose(file);
|
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);
|
bool stopped_externally = (ctx->state == STATE_IDLE);
|
||||||
|
|
||||||
@@ -652,29 +647,29 @@ static void play_wav(AppCtx* ctx) {
|
|||||||
static void on_book_selected(lv_event_t* e) {
|
static void on_book_selected(lv_event_t* e) {
|
||||||
int index = (int)(intptr_t)lv_event_get_user_data(e);
|
int index = (int)(intptr_t)lv_event_get_user_data(e);
|
||||||
AppCtx* ctx = &g_ctx;
|
AppCtx* ctx = &g_ctx;
|
||||||
|
|
||||||
BookMetadata* book = &ctx->books[index];
|
BookMetadata* book = &ctx->books[index];
|
||||||
strncpy(ctx->current_book_slug, book->slug, sizeof(ctx->current_book_slug) - 1);
|
strncpy(ctx->current_book_slug, book->slug, sizeof(ctx->current_book_slug) - 1);
|
||||||
|
|
||||||
char manifest_path[512];
|
char manifest_path[512];
|
||||||
snprintf(manifest_path, sizeof(manifest_path), "/sdcard/books/%s/manifest.json", book->slug);
|
snprintf(manifest_path, sizeof(manifest_path), "/sdcard/books/%s/manifest.json", book->slug);
|
||||||
|
|
||||||
char* json_str = read_full_file(manifest_path);
|
char* json_str = read_full_file(manifest_path);
|
||||||
if (!json_str) {
|
if (!json_str) {
|
||||||
const char* buttons[] = {"OK"};
|
const char* buttons[] = {"OK"};
|
||||||
tt_app_alertdialog_start("Read Error", "Failed to open the book manifest.", buttons, 1);
|
tt_app_alertdialog_start("Read Error", "Failed to open the book manifest.", buttons, 1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx->manifest_root = cJSON_Parse(json_str);
|
ctx->manifest_root = cJSON_Parse(json_str);
|
||||||
free(json_str);
|
free(json_str);
|
||||||
|
|
||||||
if (!ctx->manifest_root) {
|
if (!ctx->manifest_root) {
|
||||||
const char* buttons[] = {"OK"};
|
const char* buttons[] = {"OK"};
|
||||||
tt_app_alertdialog_start("JSON Error", "The book manifest is not formatted correctly.", buttons, 1);
|
tt_app_alertdialog_start("JSON Error", "The book manifest is not formatted correctly.", buttons, 1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx->pages_array = cJSON_GetObjectItem(ctx->manifest_root, "pages");
|
ctx->pages_array = cJSON_GetObjectItem(ctx->manifest_root, "pages");
|
||||||
if (!ctx->pages_array || !cJSON_IsArray(ctx->pages_array)) {
|
if (!ctx->pages_array || !cJSON_IsArray(ctx->pages_array)) {
|
||||||
const char* buttons[] = {"OK"};
|
const char* buttons[] = {"OK"};
|
||||||
@@ -684,7 +679,7 @@ static void on_book_selected(lv_event_t* e) {
|
|||||||
ctx->pages_array = NULL;
|
ctx->pages_array = NULL;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx->page_count = cJSON_GetArraySize(ctx->pages_array);
|
ctx->page_count = cJSON_GetArraySize(ctx->pages_array);
|
||||||
if (ctx->page_count <= 0) {
|
if (ctx->page_count <= 0) {
|
||||||
const char* buttons[] = {"OK"};
|
const char* buttons[] = {"OK"};
|
||||||
@@ -694,17 +689,16 @@ static void on_book_selected(lv_event_t* e) {
|
|||||||
ctx->pages_array = NULL;
|
ctx->pages_array = NULL;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
lv_label_set_text(ctx->lbl_book_title, book->title);
|
lv_label_set_text(ctx->lbl_book_title, book->title);
|
||||||
|
|
||||||
// Switch views
|
// Switch views
|
||||||
lv_obj_add_flag(ctx->picker_wrapper, LV_OBJ_FLAG_HIDDEN);
|
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->player_wrapper, LV_OBJ_FLAG_HIDDEN);
|
||||||
lv_obj_remove_flag(ctx->header_bar, 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->ctrl_bar, LV_OBJ_FLAG_HIDDEN);
|
||||||
lv_obj_remove_flag(ctx->bar_progress, 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);
|
load_page(ctx, 0, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -712,57 +706,57 @@ static void on_book_selected(lv_event_t* e) {
|
|||||||
static void scan_books(AppCtx* ctx) {
|
static void scan_books(AppCtx* ctx) {
|
||||||
ctx->book_count = 0;
|
ctx->book_count = 0;
|
||||||
lv_obj_clean(ctx->lst_books);
|
lv_obj_clean(ctx->lst_books);
|
||||||
|
|
||||||
DIR* dir = opendir("/sdcard/books");
|
DIR* dir = opendir("/sdcard/books");
|
||||||
if (!dir) {
|
if (!dir) {
|
||||||
ESP_LOGE(TAG, "Failed to scan books: /sdcard/books folder missing.");
|
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.");
|
lv_list_add_text(ctx->lst_books, "No SD card or books directory found.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
struct dirent* entry;
|
struct dirent* entry;
|
||||||
while ((entry = readdir(dir)) != NULL && ctx->book_count < MAX_BOOKS) {
|
while ((entry = readdir(dir)) != NULL && ctx->book_count < MAX_BOOKS) {
|
||||||
if (entry->d_name[0] == '.') continue;
|
if (entry->d_name[0] == '.') continue;
|
||||||
|
|
||||||
char manifest_path[512];
|
char manifest_path[512];
|
||||||
snprintf(manifest_path, sizeof(manifest_path), "/sdcard/books/%s/manifest.json", entry->d_name);
|
snprintf(manifest_path, sizeof(manifest_path), "/sdcard/books/%s/manifest.json", entry->d_name);
|
||||||
|
|
||||||
char* json_str = read_full_file(manifest_path);
|
char* json_str = read_full_file(manifest_path);
|
||||||
if (json_str) {
|
if (json_str) {
|
||||||
cJSON* root = cJSON_Parse(json_str);
|
cJSON* root = cJSON_Parse(json_str);
|
||||||
free(json_str);
|
free(json_str);
|
||||||
|
|
||||||
if (root) {
|
if (root) {
|
||||||
cJSON* title_item = cJSON_GetObjectItem(root, "title");
|
cJSON* title_item = cJSON_GetObjectItem(root, "title");
|
||||||
cJSON* author_item = cJSON_GetObjectItem(root, "author");
|
cJSON* author_item = cJSON_GetObjectItem(root, "author");
|
||||||
|
|
||||||
BookMetadata* book = &ctx->books[ctx->book_count];
|
BookMetadata* book = &ctx->books[ctx->book_count];
|
||||||
strncpy(book->slug, entry->d_name, sizeof(book->slug) - 1);
|
strncpy(book->slug, entry->d_name, sizeof(book->slug) - 1);
|
||||||
|
|
||||||
if (title_item && title_item->valuestring) {
|
if (title_item && title_item->valuestring) {
|
||||||
strncpy(book->title, title_item->valuestring, sizeof(book->title) - 1);
|
strncpy(book->title, title_item->valuestring, sizeof(book->title) - 1);
|
||||||
} else {
|
} else {
|
||||||
strncpy(book->title, entry->d_name, sizeof(book->title) - 1);
|
strncpy(book->title, entry->d_name, sizeof(book->title) - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (author_item && author_item->valuestring) {
|
if (author_item && author_item->valuestring) {
|
||||||
strncpy(book->author, author_item->valuestring, sizeof(book->author) - 1);
|
strncpy(book->author, author_item->valuestring, sizeof(book->author) - 1);
|
||||||
} else {
|
} else {
|
||||||
book->author[0] = '\0';
|
book->author[0] = '\0';
|
||||||
}
|
}
|
||||||
|
|
||||||
cJSON_Delete(root);
|
cJSON_Delete(root);
|
||||||
ctx->book_count++;
|
ctx->book_count++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
closedir(dir);
|
closedir(dir);
|
||||||
|
|
||||||
if (ctx->book_count == 0) {
|
if (ctx->book_count == 0) {
|
||||||
lv_list_add_text(ctx->lst_books, "No book manifest.json files found.");
|
lv_list_add_text(ctx->lst_books, "No book manifest.json files found.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bubble sort by title
|
// Bubble sort by title
|
||||||
for (int i = 0; i < ctx->book_count - 1; i++) {
|
for (int i = 0; i < ctx->book_count - 1; i++) {
|
||||||
for (int j = 0; j < ctx->book_count - i - 1; j++) {
|
for (int j = 0; j < ctx->book_count - i - 1; j++) {
|
||||||
@@ -773,7 +767,7 @@ static void scan_books(AppCtx* ctx) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add items to screen list
|
// Add items to screen list
|
||||||
for (int i = 0; i < ctx->book_count; ++i) {
|
for (int i = 0; i < ctx->book_count; ++i) {
|
||||||
char label_text[512];
|
char label_text[512];
|
||||||
@@ -795,7 +789,7 @@ static void on_play_pause_click(lv_event_t* e) {
|
|||||||
if (ctx->state == STATE_IDLE) {
|
if (ctx->state == STATE_IDLE) {
|
||||||
ctx->state = STATE_PLAYING;
|
ctx->state = STATE_PLAYING;
|
||||||
update_ui(ctx);
|
update_ui(ctx);
|
||||||
xTaskCreate(audio_playback_task, "audio_play", 4096, ctx, 5, &ctx->playback_task_handle);
|
xTaskCreate(audio_playback_task, "audio_play", 8192, ctx, 5, &ctx->playback_task_handle);
|
||||||
} else if (ctx->state == STATE_PLAYING) {
|
} else if (ctx->state == STATE_PLAYING) {
|
||||||
ctx->state = STATE_PAUSED;
|
ctx->state = STATE_PAUSED;
|
||||||
update_ui(ctx);
|
update_ui(ctx);
|
||||||
@@ -824,7 +818,7 @@ static void on_next_click(lv_event_t* e) {
|
|||||||
static void on_image_click(lv_event_t* e) {
|
static void on_image_click(lv_event_t* e) {
|
||||||
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
|
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
bool is_hidden = lv_obj_has_flag(ctx->header_bar, LV_OBJ_FLAG_HIDDEN);
|
bool is_hidden = lv_obj_has_flag(ctx->header_bar, LV_OBJ_FLAG_HIDDEN);
|
||||||
if (is_hidden) {
|
if (is_hidden) {
|
||||||
lv_obj_remove_flag(ctx->header_bar, LV_OBJ_FLAG_HIDDEN);
|
lv_obj_remove_flag(ctx->header_bar, LV_OBJ_FLAG_HIDDEN);
|
||||||
@@ -847,7 +841,7 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
|||||||
memset(&g_ctx, 0, sizeof(g_ctx));
|
memset(&g_ctx, 0, sizeof(g_ctx));
|
||||||
g_ctx.app = app;
|
g_ctx.app = app;
|
||||||
g_ctx.volume = 80;
|
g_ctx.volume = 80;
|
||||||
|
|
||||||
// Allocate shared audio buffers
|
// Allocate shared audio buffers
|
||||||
#ifdef ESP_PLATFORM
|
#ifdef ESP_PLATFORM
|
||||||
g_ctx.audio_buf = (uint8_t*)heap_caps_malloc(MP3_INPUT_BUFFER_SIZE, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
g_ctx.audio_buf = (uint8_t*)heap_caps_malloc(MP3_INPUT_BUFFER_SIZE, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||||
@@ -856,13 +850,12 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
|||||||
g_ctx.audio_buf = (uint8_t*)malloc(MP3_INPUT_BUFFER_SIZE);
|
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));
|
g_ctx.pcm_buf = (mp3d_sample_t*)malloc(MINIMP3_MAX_SAMPLES_PER_FRAME * sizeof(mp3d_sample_t));
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Find I2S sound device
|
// Find audio-stream device (provides resampling to native 44100)
|
||||||
g_ctx.i2s_dev = device_find_by_name("i2s0");
|
if (!find_audio_stream_device(&g_ctx)) {
|
||||||
if (!g_ctx.i2s_dev) {
|
ESP_LOGE(TAG, "audio-stream device not found! Tried 'audio-stream' name and AUDIO_STREAM_TYPE");
|
||||||
ESP_LOGE(TAG, "I2S device 'i2s0' not found!");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create dual layouts
|
// Create dual layouts
|
||||||
// 1. Picker Screen wrapper
|
// 1. Picker Screen wrapper
|
||||||
g_ctx.picker_wrapper = lv_obj_create(parent);
|
g_ctx.picker_wrapper = lv_obj_create(parent);
|
||||||
@@ -870,13 +863,11 @@ 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_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_all(g_ctx.picker_wrapper, 0, 0);
|
||||||
lv_obj_set_style_pad_gap(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); // Dark background
|
lv_obj_set_style_bg_color(g_ctx.picker_wrapper, lv_color_hex(0x1E1E2E), 0);
|
||||||
|
|
||||||
// Toolbar in picker
|
|
||||||
lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(g_ctx.picker_wrapper, app);
|
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);
|
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
|
||||||
|
|
||||||
// Picker list
|
|
||||||
g_ctx.lst_books = lv_list_create(g_ctx.picker_wrapper);
|
g_ctx.lst_books = lv_list_create(g_ctx.picker_wrapper);
|
||||||
lv_obj_set_width(g_ctx.lst_books, LV_PCT(100));
|
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);
|
lv_obj_align_to(g_ctx.lst_books, toolbar, LV_ALIGN_OUT_BOTTOM_MID, 0, 0);
|
||||||
@@ -885,7 +876,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_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_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);
|
lv_obj_set_style_border_color(g_ctx.lst_books, lv_color_hex(0x313244), 0);
|
||||||
|
|
||||||
// 2. Player Screen wrapper (hidden on start)
|
// 2. Player Screen wrapper (hidden on start)
|
||||||
g_ctx.player_wrapper = lv_obj_create(parent);
|
g_ctx.player_wrapper = lv_obj_create(parent);
|
||||||
lv_obj_set_size(g_ctx.player_wrapper, LV_PCT(100), LV_PCT(100));
|
lv_obj_set_size(g_ctx.player_wrapper, LV_PCT(100), LV_PCT(100));
|
||||||
@@ -893,10 +884,9 @@ 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_all(g_ctx.player_wrapper, 0, 0);
|
||||||
lv_obj_set_style_pad_gap(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_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); // Lock page scrolling
|
lv_obj_remove_flag(g_ctx.player_wrapper, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
lv_obj_add_flag(g_ctx.player_wrapper, LV_OBJ_FLAG_HIDDEN);
|
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);
|
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_align(g_ctx.img_page, LV_ALIGN_CENTER, 0, 0);
|
||||||
lv_obj_set_style_bg_opa(g_ctx.img_page, LV_OPA_TRANSP, 0);
|
lv_obj_set_style_bg_opa(g_ctx.img_page, LV_OPA_TRANSP, 0);
|
||||||
@@ -905,20 +895,18 @@ 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_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_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);
|
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);
|
g_ctx.header_bar = lv_obj_create(g_ctx.player_wrapper);
|
||||||
lv_obj_t* header_bar = g_ctx.header_bar;
|
lv_obj_t* header_bar = g_ctx.header_bar;
|
||||||
lv_obj_set_size(header_bar, LV_PCT(100), 36);
|
lv_obj_set_size(header_bar, LV_PCT(100), 36);
|
||||||
lv_obj_align(header_bar, LV_ALIGN_TOP_MID, 0, 0);
|
lv_obj_align(header_bar, LV_ALIGN_TOP_MID, 0, 0);
|
||||||
lv_obj_set_style_radius(header_bar, 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_color(header_bar, lv_color_hex(0x11111B), 0);
|
||||||
lv_obj_set_style_bg_opa(header_bar, LV_OPA_COVER, 0); // Solid header bar
|
lv_obj_set_style_bg_opa(header_bar, LV_OPA_COVER, 0);
|
||||||
lv_obj_set_style_border_color(header_bar, lv_color_hex(0x313244), 0);
|
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_set_style_border_width(header_bar, 1, LV_PART_MAIN);
|
||||||
lv_obj_remove_flag(header_bar, LV_OBJ_FLAG_SCROLLABLE);
|
lv_obj_remove_flag(header_bar, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
// Back button
|
|
||||||
lv_obj_t* btn_back = lv_button_create(header_bar);
|
lv_obj_t* btn_back = lv_button_create(header_bar);
|
||||||
lv_obj_set_size(btn_back, 44, 26);
|
lv_obj_set_size(btn_back, 44, 26);
|
||||||
lv_obj_align(btn_back, LV_ALIGN_LEFT_MID, 0, 0);
|
lv_obj_align(btn_back, LV_ALIGN_LEFT_MID, 0, 0);
|
||||||
@@ -928,37 +916,33 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
|||||||
lv_label_set_text(lbl_back, LV_SYMBOL_LEFT);
|
lv_label_set_text(lbl_back, LV_SYMBOL_LEFT);
|
||||||
lv_obj_center(lbl_back);
|
lv_obj_center(lbl_back);
|
||||||
lv_obj_add_event_cb(btn_back, on_back_click, LV_EVENT_CLICKED, &g_ctx);
|
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);
|
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_align(g_ctx.lbl_book_title, LV_ALIGN_CENTER, 0, 0);
|
||||||
lv_obj_set_width(g_ctx.lbl_book_title, 180);
|
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_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_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); // Static text with dots to prevent dynamic redraw overhead
|
lv_label_set_long_mode(g_ctx.lbl_book_title, LV_LABEL_LONG_DOT);
|
||||||
|
|
||||||
// Page Indicator
|
|
||||||
g_ctx.lbl_indicator = lv_label_create(header_bar);
|
g_ctx.lbl_indicator = lv_label_create(header_bar);
|
||||||
lv_obj_align(g_ctx.lbl_indicator, LV_ALIGN_RIGHT_MID, 0, 0);
|
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_obj_set_style_text_color(g_ctx.lbl_indicator, lv_color_hex(0xA6ADC8), 0);
|
||||||
lv_label_set_text(g_ctx.lbl_indicator, "1 / 1");
|
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);
|
g_ctx.ctrl_bar = lv_obj_create(g_ctx.player_wrapper);
|
||||||
lv_obj_t* ctrl_bar = g_ctx.ctrl_bar;
|
lv_obj_t* ctrl_bar = g_ctx.ctrl_bar;
|
||||||
lv_obj_set_size(ctrl_bar, LV_PCT(100), 40);
|
lv_obj_set_size(ctrl_bar, LV_PCT(100), 40);
|
||||||
lv_obj_align(ctrl_bar, LV_ALIGN_BOTTOM_MID, 0, 0);
|
lv_obj_align(ctrl_bar, LV_ALIGN_BOTTOM_MID, 0, 0);
|
||||||
lv_obj_set_style_radius(ctrl_bar, 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_color(ctrl_bar, lv_color_hex(0x11111B), 0);
|
||||||
lv_obj_set_style_bg_opa(ctrl_bar, LV_OPA_COVER, 0); // Solid control bar
|
lv_obj_set_style_bg_opa(ctrl_bar, LV_OPA_COVER, 0);
|
||||||
lv_obj_set_style_border_width(ctrl_bar, 0, 0);
|
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_all(ctrl_bar, 0, 0);
|
||||||
lv_obj_set_style_pad_gap(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_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_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);
|
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);
|
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_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);
|
lv_obj_align_to(g_ctx.bar_progress, ctrl_bar, LV_ALIGN_OUT_TOP_MID, 0, 0);
|
||||||
@@ -966,8 +950,7 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
|||||||
lv_bar_set_value(g_ctx.bar_progress, 0, LV_ANIM_OFF);
|
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(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(0x89B4FA), LV_PART_INDICATOR);
|
||||||
|
|
||||||
// Prev Button
|
|
||||||
g_ctx.btn_prev = lv_button_create(ctrl_bar);
|
g_ctx.btn_prev = lv_button_create(ctrl_bar);
|
||||||
lv_obj_set_size(g_ctx.btn_prev, 54, 30);
|
lv_obj_set_size(g_ctx.btn_prev, 54, 30);
|
||||||
lv_obj_set_style_radius(g_ctx.btn_prev, 15, 0);
|
lv_obj_set_style_radius(g_ctx.btn_prev, 15, 0);
|
||||||
@@ -976,8 +959,7 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
|||||||
lv_label_set_text(lbl_prev, LV_SYMBOL_PREV);
|
lv_label_set_text(lbl_prev, LV_SYMBOL_PREV);
|
||||||
lv_obj_center(lbl_prev);
|
lv_obj_center(lbl_prev);
|
||||||
lv_obj_add_event_cb(g_ctx.btn_prev, on_prev_click, LV_EVENT_CLICKED, &g_ctx);
|
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);
|
g_ctx.btn_play_pause = lv_button_create(ctrl_bar);
|
||||||
lv_obj_set_size(g_ctx.btn_play_pause, 94, 30);
|
lv_obj_set_size(g_ctx.btn_play_pause, 94, 30);
|
||||||
lv_obj_set_style_radius(g_ctx.btn_play_pause, 15, 0);
|
lv_obj_set_style_radius(g_ctx.btn_play_pause, 15, 0);
|
||||||
@@ -987,8 +969,7 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
|||||||
lv_label_set_text(lbl_play_btn, LV_SYMBOL_PLAY " Play");
|
lv_label_set_text(lbl_play_btn, LV_SYMBOL_PLAY " Play");
|
||||||
lv_obj_center(lbl_play_btn);
|
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);
|
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);
|
g_ctx.btn_next = lv_button_create(ctrl_bar);
|
||||||
lv_obj_set_size(g_ctx.btn_next, 54, 30);
|
lv_obj_set_size(g_ctx.btn_next, 54, 30);
|
||||||
lv_obj_set_style_radius(g_ctx.btn_next, 15, 0);
|
lv_obj_set_style_radius(g_ctx.btn_next, 15, 0);
|
||||||
@@ -1001,26 +982,20 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
|||||||
// Initial load
|
// Initial load
|
||||||
g_ctx.state = STATE_IDLE;
|
g_ctx.state = STATE_IDLE;
|
||||||
update_ui(&g_ctx);
|
update_ui(&g_ctx);
|
||||||
|
|
||||||
// Scan Books
|
|
||||||
scan_books(&g_ctx);
|
scan_books(&g_ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void onHideApp(AppHandle app, void* data) {
|
static void onHideApp(AppHandle app, void* data) {
|
||||||
wait_for_playback_task_to_exit(&g_ctx);
|
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) {
|
if (g_ctx.manifest_root) {
|
||||||
cJSON_Delete(g_ctx.manifest_root);
|
cJSON_Delete(g_ctx.manifest_root);
|
||||||
g_ctx.manifest_root = NULL;
|
g_ctx.manifest_root = NULL;
|
||||||
g_ctx.pages_array = NULL;
|
g_ctx.pages_array = NULL;
|
||||||
}
|
}
|
||||||
|
|
||||||
#ifdef ESP_PLATFORM
|
#ifdef ESP_PLATFORM
|
||||||
if (g_ctx.audio_buf) {
|
if (g_ctx.audio_buf) {
|
||||||
heap_caps_free(g_ctx.audio_buf);
|
heap_caps_free(g_ctx.audio_buf);
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32s3
|
||||||
sdk=0.8.0-dev
|
app.id=one.tactility.bookplayer
|
||||||
platforms=esp32s3
|
app.version.name=1.0.0
|
||||||
[app]
|
app.version.code=1
|
||||||
id=one.tactility.bookplayer
|
app.name=Book Player
|
||||||
versionName=1.0.0
|
|
||||||
versionCode=1
|
|
||||||
name=Book Player
|
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.brainfuck
|
||||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
app.version.name=0.5.0
|
||||||
[app]
|
app.version.code=5
|
||||||
id=one.tactility.brainfuck
|
app.name=Brainfuck interpreter
|
||||||
versionName=0.2.0
|
app.description=Brainfuck esoteric language interpreter
|
||||||
versionCode=2
|
|
||||||
name=Brainfuck interpreter
|
|
||||||
description=Brainfuck esoteric language interpreter
|
|
||||||
|
|||||||
@@ -124,7 +124,6 @@ void Breakout::onShow(AppHandle appHandle, lv_obj_t* parent) {
|
|||||||
if (!sfxEngine) {
|
if (!sfxEngine) {
|
||||||
sfxEngine = new SfxEngine();
|
sfxEngine = new SfxEngine();
|
||||||
sfxEngine->start();
|
sfxEngine->start();
|
||||||
sfxEngine->applyVolumePreset(SfxEngine::VolumePreset::Quiet);
|
|
||||||
sfxEngine->setEnabled(soundEnabled);
|
sfxEngine->setEnabled(soundEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.breakout
|
||||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
app.version.name=0.5.0
|
||||||
[app]
|
app.version.code=5
|
||||||
id=one.tactility.breakout
|
app.name=Breakout
|
||||||
versionName=0.2.0
|
app.description=Classic brick-breaking arcade game
|
||||||
versionCode=2
|
|
||||||
name=Breakout
|
|
||||||
description=Classic brick-breaking arcade game
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.calculator
|
||||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
app.version.name=0.6.0
|
||||||
[app]
|
app.version.code=6
|
||||||
id=one.tactility.calculator
|
app.name=Calculator
|
||||||
versionName=0.3.0
|
|
||||||
versionCode=3
|
|
||||||
name=Calculator
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.diceware
|
||||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
app.version.name=0.6.0
|
||||||
[app]
|
app.version.code=6
|
||||||
id=one.tactility.diceware
|
app.name=Diceware
|
||||||
versionName=0.3.0
|
|
||||||
versionCode=3
|
|
||||||
name=Diceware
|
|
||||||
|
|||||||
@@ -1,390 +0,0 @@
|
|||||||
#include <tt_app.h>
|
|
||||||
#include <tt_lvgl.h>
|
|
||||||
#include <tt_lvgl_toolbar.h>
|
|
||||||
#include <tt_bundle.h>
|
|
||||||
#include <tactility/device.h>
|
|
||||||
#include <tactility/drivers/i2s_controller.h>
|
|
||||||
#include <string.h>
|
|
||||||
#include <stdlib.h>
|
|
||||||
#include <stdio.h>
|
|
||||||
#include <dirent.h>
|
|
||||||
#include <sys/stat.h>
|
|
||||||
#include <unistd.h>
|
|
||||||
#include "freertos/FreeRTOS.h"
|
|
||||||
#include "freertos/task.h"
|
|
||||||
#include "esp_log.h"
|
|
||||||
#include "esp_http_client.h"
|
|
||||||
#include "cJSON.h"
|
|
||||||
#define MINIMP3_IMPLEMENTATION
|
|
||||||
#define MINIMP3_NO_SIMD
|
|
||||||
#include "minimp3.h"
|
|
||||||
|
|
||||||
#define TAG "DM"
|
|
||||||
#define PB "http://192.168.68.110:8095"
|
|
||||||
#define DM_DIR "/sdcard/dm"
|
|
||||||
#define STATE_PATH "/sdcard/apps/one.tactility.discoverymountain/userdata/state.json"
|
|
||||||
#define MAX_SEASONS 40
|
|
||||||
#define MAX_EPS 400
|
|
||||||
#define MP3_BUF 16384
|
|
||||||
|
|
||||||
typedef struct { int num; } SeasonInfo;
|
|
||||||
typedef struct { char title[128]; char slug[96]; int season; int ep_num; char audio_url[256]; bool seen; } EpInfo;
|
|
||||||
|
|
||||||
static struct {
|
|
||||||
SeasonInfo seasons[MAX_SEASONS]; int season_cnt;
|
|
||||||
EpInfo eps[MAX_EPS]; int ep_cnt;
|
|
||||||
int cur_season;
|
|
||||||
int cur_ep_idx;
|
|
||||||
struct Device* i2s_dev;
|
|
||||||
bool is_playing; bool is_paused; bool need_stop;
|
|
||||||
TaskHandle_t play_handle;
|
|
||||||
int pos_sec; int total_sec;
|
|
||||||
int volume;
|
|
||||||
lv_obj_t *lbl_title, *lbl_meta, *bar, *lbl_time, *lbl_status, *btn_play;
|
|
||||||
lv_obj_t *overlay, *roller;
|
|
||||||
char cur_title[128];
|
|
||||||
} G;
|
|
||||||
|
|
||||||
static const char* sd_path(const char* slug){ static char p[256]; snprintf(p,sizeof(p),"%s/%s.mp3",DM_DIR,slug); return p; }
|
|
||||||
static void ensure_dir(){ mkdir("/sdcard",0777); mkdir("/sdcard/apps",0777); mkdir("/sdcard/apps/one.tactility.discoverymountain",0777); mkdir("/sdcard/apps/one.tactility.discoverymountain/userdata",0777); mkdir(DM_DIR,0777); }
|
|
||||||
static bool has_slug(const char* slug){ struct stat st; return stat(sd_path(slug),&st)==0; }
|
|
||||||
|
|
||||||
static void save_state(){
|
|
||||||
cJSON* r=cJSON_CreateObject();
|
|
||||||
if(G.ep_cnt>0 && G.cur_ep_idx>=0) cJSON_AddStringToObject(r,"last_slug",G.eps[G.cur_ep_idx].slug);
|
|
||||||
cJSON_AddNumberToObject(r,"last_season",G.cur_season);
|
|
||||||
cJSON_AddNumberToObject(r,"pos_sec",G.pos_sec);
|
|
||||||
char* s=cJSON_PrintUnformatted(r);
|
|
||||||
if(s){ FILE* f=fopen(STATE_PATH,"w"); if(f){ fwrite(s,1,strlen(s),f); fclose(f);} free(s);} cJSON_Delete(r);
|
|
||||||
}
|
|
||||||
static void load_state(){
|
|
||||||
G.cur_season=1; G.cur_ep_idx=-1; G.pos_sec=0;
|
|
||||||
FILE* f=fopen(STATE_PATH,"r"); if(!f) return;
|
|
||||||
fseek(f,0,SEEK_END); long sz=ftell(f); fseek(f,0,SEEK_SET); if(sz<=0||sz>4096){ fclose(f); return;}
|
|
||||||
char* buf=malloc(sz+1); fread(buf,1,sz,f); buf[sz]=0; fclose(f);
|
|
||||||
cJSON* r=cJSON_Parse(buf); free(buf); if(!r) return;
|
|
||||||
cJSON* ls=cJSON_GetObjectItem(r,"last_slug");
|
|
||||||
cJSON* lsn=cJSON_GetObjectItem(r,"last_season");
|
|
||||||
cJSON* ps=cJSON_GetObjectItem(r,"pos_sec");
|
|
||||||
if(ps&&cJSON_IsNumber(ps)) G.pos_sec=ps->valueint;
|
|
||||||
if(lsn&&cJSON_IsNumber(lsn)) G.cur_season=lsn->valueint;
|
|
||||||
if(ls&&cJSON_IsString(ls)){
|
|
||||||
for(int i=0;i<G.ep_cnt;i++) if(strcmp(G.eps[i].slug,ls->valuestring)==0){ G.cur_ep_idx=i; G.cur_season=G.eps[i].season; break; }
|
|
||||||
}
|
|
||||||
cJSON_Delete(r);
|
|
||||||
}
|
|
||||||
|
|
||||||
static int cmp_season(const void*a,const void*b){ return ((SeasonInfo*)a)->num - ((SeasonInfo*)b)->num; }
|
|
||||||
static int cmp_ep(const void*a,const void*b){ EpInfo* ea=(EpInfo*)a; EpInfo* eb=(EpInfo*)b; if(ea->season!=eb->season) return ea->season-eb->season; return ea->ep_num-eb->ep_num; }
|
|
||||||
|
|
||||||
static int http_get(const char* url, char** out){
|
|
||||||
esp_http_client_config_t cfg={ .url=url, .timeout_ms=8000 };
|
|
||||||
esp_http_client_handle_t c=esp_http_client_init(&cfg); if(!c) return -1;
|
|
||||||
esp_http_client_set_method(c,HTTP_METHOD_GET);
|
|
||||||
if(esp_http_client_perform(c)!=ESP_OK){ esp_http_client_cleanup(c); return -1; }
|
|
||||||
if(esp_http_client_get_status_code(c)!=200){ esp_http_client_cleanup(c); return -1; }
|
|
||||||
int len=esp_http_client_get_content_length(c); if(len<=0) len=65536;
|
|
||||||
char* buf=malloc(len+1); int rlen=esp_http_client_read_response(c,buf,len); buf[rlen]=0;
|
|
||||||
esp_http_client_cleanup(c); *out=buf; return rlen;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void fetch_data(){
|
|
||||||
char* js=NULL; char url[256];
|
|
||||||
snprintf(url,sizeof(url),"%s/api/collections/dm_seasons/records?perPage=100&sort=season_num",PB);
|
|
||||||
if(http_get(url,&js)>0){
|
|
||||||
cJSON* root=cJSON_Parse(js); free(js); js=NULL;
|
|
||||||
if(root){
|
|
||||||
cJSON* items=cJSON_GetObjectItem(root,"items");
|
|
||||||
if(cJSON_IsArray(items)){
|
|
||||||
G.season_cnt=0;
|
|
||||||
int n=cJSON_GetArraySize(items);
|
|
||||||
for(int i=0;i<n&&G.season_cnt<MAX_SEASONS;i++){
|
|
||||||
cJSON* it=cJSON_GetArrayItem(items,i);
|
|
||||||
cJSON* sn=cJSON_GetObjectItem(it,"season_num");
|
|
||||||
if(sn&&cJSON_IsNumber(sn)){ G.seasons[G.season_cnt].num=sn->valueint; G.season_cnt++; }
|
|
||||||
}
|
|
||||||
qsort(G.seasons,G.season_cnt,sizeof(SeasonInfo),cmp_season);
|
|
||||||
}
|
|
||||||
cJSON_Delete(root);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if(G.season_cnt==0){ for(int i=1;i<=37;i++) G.seasons[i-1].num=i; G.season_cnt=37; }
|
|
||||||
|
|
||||||
snprintf(url,sizeof(url),"%s/api/collections/dm_episodes/records?perPage=400&sort=season_num,episode_num",PB);
|
|
||||||
if(http_get(url,&js)>0){
|
|
||||||
cJSON* root=cJSON_Parse(js); free(js); js=NULL;
|
|
||||||
if(root){
|
|
||||||
cJSON* items=cJSON_GetObjectItem(root,"items");
|
|
||||||
if(cJSON_IsArray(items)){
|
|
||||||
G.ep_cnt=0;
|
|
||||||
int n=cJSON_GetArraySize(items);
|
|
||||||
for(int i=0;i<n&&G.ep_cnt<MAX_EPS;i++){
|
|
||||||
cJSON* it=cJSON_GetArrayItem(items,i);
|
|
||||||
cJSON* t=cJSON_GetObjectItem(it,"title");
|
|
||||||
cJSON* slug=cJSON_GetObjectItem(it,"slug");
|
|
||||||
cJSON* sn=cJSON_GetObjectItem(it,"season_num");
|
|
||||||
cJSON* en=cJSON_GetObjectItem(it,"episode_num");
|
|
||||||
cJSON* au=cJSON_GetObjectItem(it,"audio_url");
|
|
||||||
if(!sn||!en) continue;
|
|
||||||
EpInfo* e=&G.eps[G.ep_cnt]; memset(e,0,sizeof(*e));
|
|
||||||
if(t&&cJSON_IsString(t)) strncpy(e->title,t->valuestring,sizeof(e->title)-1);
|
|
||||||
if(slug&&cJSON_IsString(slug)) strncpy(e->slug,slug->valuestring,sizeof(e->slug)-1);
|
|
||||||
else if(t) strncpy(e->slug,t->valuestring,sizeof(e->slug)-1);
|
|
||||||
e->season=sn->valueint; e->ep_num=en->valueint;
|
|
||||||
if(au&&cJSON_IsString(au)) strncpy(e->audio_url,au->valuestring,sizeof(e->audio_url)-1);
|
|
||||||
e->seen=has_slug(e->slug);
|
|
||||||
G.ep_cnt++;
|
|
||||||
}
|
|
||||||
qsort(G.eps,G.ep_cnt,sizeof(EpInfo),cmp_ep);
|
|
||||||
}
|
|
||||||
cJSON_Delete(root);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if(G.ep_cnt==0){
|
|
||||||
for(int i=0;i<6;i++){ EpInfo* e=&G.eps[i]; snprintf(e->slug,sizeof(e->slug),"S01E%02d",i+1); snprintf(e->title,sizeof(e->title),"Episode %d",i+1); e->season=1; e->ep_num=i+1; }
|
|
||||||
G.ep_cnt=6;
|
|
||||||
}
|
|
||||||
for(int i=0;i<G.ep_cnt;i++) G.eps[i].seen=has_slug(G.eps[i].slug);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void lru_check(){
|
|
||||||
typedef struct{ char path[300]; time_t mt; } FE;
|
|
||||||
FE files[200];
|
|
||||||
int fc=0;
|
|
||||||
DIR* d=opendir(DM_DIR);
|
|
||||||
if(!d) return;
|
|
||||||
struct dirent* de;
|
|
||||||
while((de=readdir(d))!=NULL && fc<200){
|
|
||||||
if(de->d_name[0]=='.') continue;
|
|
||||||
size_t l=strlen(de->d_name);
|
|
||||||
if(l<5) continue;
|
|
||||||
if(strcmp(de->d_name+l-4,".mp3")!=0) continue;
|
|
||||||
char full[300];
|
|
||||||
snprintf(full,sizeof(full),"%s/%.200s",DM_DIR,de->d_name);
|
|
||||||
struct stat st;
|
|
||||||
if(stat(full,&st)!=0) continue;
|
|
||||||
if(G.cur_ep_idx>=0){
|
|
||||||
const char* curp=sd_path(G.eps[G.cur_ep_idx].slug);
|
|
||||||
if(strcmp(full,curp)==0) continue;
|
|
||||||
}
|
|
||||||
strncpy(files[fc].path,full,sizeof(files[fc].path)-1);
|
|
||||||
files[fc].mt=st.st_mtime;
|
|
||||||
fc++;
|
|
||||||
}
|
|
||||||
closedir(d);
|
|
||||||
if(fc<12) return;
|
|
||||||
for(int i=0;i<fc;i++){
|
|
||||||
for(int j=i+1;j<fc;j++){
|
|
||||||
if(files[j].mt<files[i].mt){ FE t=files[i]; files[i]=files[j]; files[j]=t; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
int todel=fc-8;
|
|
||||||
for(int i=0;i<todel;i++) unlink(files[i].path);
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool dl_ep(EpInfo* ep){
|
|
||||||
if(!ep) return false;
|
|
||||||
if(has_slug(ep->slug)) return true;
|
|
||||||
ensure_dir(); lru_check();
|
|
||||||
if(strlen(ep->audio_url)==0) return false;
|
|
||||||
char tmp[300]; snprintf(tmp,sizeof(tmp),"%s.tmp",sd_path(ep->slug));
|
|
||||||
FILE* f=fopen(tmp,"wb"); if(!f) return false;
|
|
||||||
esp_http_client_config_t cfg={ .url=ep->audio_url, .timeout_ms=30000 };
|
|
||||||
esp_http_client_handle_t c=esp_http_client_init(&cfg); if(!c){ fclose(f); return false; }
|
|
||||||
if(esp_http_client_perform(c)!=ESP_OK){ esp_http_client_cleanup(c); fclose(f); unlink(tmp); return false; }
|
|
||||||
if(esp_http_client_get_status_code(c)!=200){ esp_http_client_cleanup(c); fclose(f); unlink(tmp); return false; }
|
|
||||||
char buf[2048]; int total=0;
|
|
||||||
while(1){
|
|
||||||
int r=esp_http_client_read(c,buf,sizeof(buf));
|
|
||||||
if(r<=0) break;
|
|
||||||
fwrite(buf,1,r,f);
|
|
||||||
total+=r;
|
|
||||||
if(G.lbl_status){ char m[32]; snprintf(m,sizeof(m),"DL %dKB",total/1024); lv_label_set_text(G.lbl_status,m);}
|
|
||||||
}
|
|
||||||
esp_http_client_cleanup(c); fclose(f);
|
|
||||||
if(total<1024){ unlink(tmp); return false; }
|
|
||||||
rename(tmp,sd_path(ep->slug));
|
|
||||||
ep->seen=true;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void stop_play(){
|
|
||||||
if(G.play_handle){ G.need_stop=true; vTaskDelay(pdMS_TO_TICKS(100)); if(G.play_handle){ vTaskDelete(G.play_handle); G.play_handle=NULL; } }
|
|
||||||
if(G.i2s_dev){ device_lock(G.i2s_dev); i2s_controller_reset(G.i2s_dev); device_unlock(G.i2s_dev); G.i2s_dev=NULL; }
|
|
||||||
G.is_playing=false; G.is_paused=false;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void play_task(void* arg){
|
|
||||||
(void)arg;
|
|
||||||
int idx=G.cur_ep_idx;
|
|
||||||
if(idx<0||idx>=G.ep_cnt){ G.play_handle=NULL; vTaskDelete(NULL); return; }
|
|
||||||
EpInfo* ep=&G.eps[idx];
|
|
||||||
FILE* file=fopen(sd_path(ep->slug),"rb");
|
|
||||||
if(!file){ G.is_playing=false; G.play_handle=NULL; vTaskDelete(NULL); return; }
|
|
||||||
fseek(file,0,SEEK_END); long fsize=ftell(file); fseek(file,0,SEEK_SET);
|
|
||||||
G.i2s_dev=device_find_by_name("i2s0");
|
|
||||||
if(!G.i2s_dev){ fclose(file); G.is_playing=false; G.play_handle=NULL; vTaskDelete(NULL); return; }
|
|
||||||
if(fsize>0) G.total_sec=(int)(fsize/16000);
|
|
||||||
|
|
||||||
mp3dec_t dec; mp3dec_init(&dec);
|
|
||||||
uint8_t* inbuf=malloc(MP3_BUF);
|
|
||||||
int16_t* pcm=malloc(1152*2*2);
|
|
||||||
size_t buffered=0;
|
|
||||||
mp3dec_frame_info_t info; memset(&info,0,sizeof(info));
|
|
||||||
bool configured=false;
|
|
||||||
size_t r=fread(inbuf,1,MP3_BUF,file); buffered=r;
|
|
||||||
|
|
||||||
while(!G.need_stop){
|
|
||||||
if(G.is_paused){ vTaskDelay(pdMS_TO_TICKS(100)); continue; }
|
|
||||||
int samples=mp3dec_decode_frame(&dec,inbuf,(int)buffered,pcm,&info);
|
|
||||||
if(samples>0){
|
|
||||||
if(!configured){
|
|
||||||
struct I2sConfig cfg={ .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(G.i2s_dev); i2s_controller_set_config(G.i2s_dev,&cfg); device_unlock(G.i2s_dev);
|
|
||||||
if(G.pos_sec>5){ long seek=(long)G.pos_sec*16000; if(seek<fsize){ fseek(file,seek,SEEK_SET); buffered=0; } }
|
|
||||||
configured=true;
|
|
||||||
}
|
|
||||||
float vol=G.volume/100.0f; if(vol<0) vol=0; if(vol>1) vol=1;
|
|
||||||
for(int i=0;i<samples*2;i++){ int32_t s=pcm[i]; s=(int32_t)(s*vol); if(s>32767) s=32767; if(s<-32768) s=-32768; pcm[i]=(int16_t)s; }
|
|
||||||
size_t to_write=samples*2*2; size_t off=0;
|
|
||||||
while(off<to_write && !G.need_stop){
|
|
||||||
if(G.is_paused){ vTaskDelay(pdMS_TO_TICKS(100)); continue; }
|
|
||||||
size_t w=0;
|
|
||||||
device_lock(G.i2s_dev);
|
|
||||||
esp_err_t e=i2s_controller_write(G.i2s_dev,(uint8_t*)pcm+off,to_write-off,&w,pdMS_TO_TICKS(500));
|
|
||||||
device_unlock(G.i2s_dev);
|
|
||||||
if(e==ESP_OK) off+=w; else vTaskDelay(pdMS_TO_TICKS(10));
|
|
||||||
}
|
|
||||||
if(info.hz>0) G.pos_sec += samples / info.hz;
|
|
||||||
}
|
|
||||||
if(info.frame_bytes>0 && info.frame_bytes <= (int)buffered){
|
|
||||||
memmove(inbuf,inbuf+info.frame_bytes,buffered-info.frame_bytes);
|
|
||||||
buffered-=info.frame_bytes;
|
|
||||||
} else buffered=0;
|
|
||||||
if(buffered < MP3_BUF/2){
|
|
||||||
size_t nr=fread(inbuf+buffered,1,MP3_BUF-buffered,file);
|
|
||||||
if(nr>0) buffered+=nr; else { if(samples==0) break; }
|
|
||||||
}
|
|
||||||
if(buffered==0) break;
|
|
||||||
}
|
|
||||||
fclose(file); free(inbuf); free(pcm);
|
|
||||||
if(G.i2s_dev){ device_lock(G.i2s_dev); i2s_controller_reset(G.i2s_dev); device_unlock(G.i2s_dev); G.i2s_dev=NULL; }
|
|
||||||
G.is_playing=false; G.play_handle=NULL; vTaskDelete(NULL);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void start_idx(int idx){
|
|
||||||
if(idx<0||idx>=G.ep_cnt) return;
|
|
||||||
if(G.cur_ep_idx!=idx) G.pos_sec=0;
|
|
||||||
EpInfo* ep=&G.eps[idx];
|
|
||||||
if(!has_slug(ep->slug)){
|
|
||||||
if(G.lbl_status) lv_label_set_text(G.lbl_status,"Downloading...");
|
|
||||||
if(!dl_ep(ep)){ if(G.lbl_status) lv_label_set_text(G.lbl_status,"DL failed"); return; }
|
|
||||||
}
|
|
||||||
stop_play();
|
|
||||||
G.cur_ep_idx=idx; G.cur_season=ep->season; strncpy(G.cur_title,ep->title,sizeof(G.cur_title)-1);
|
|
||||||
G.need_stop=false; G.is_paused=false; G.is_playing=true;
|
|
||||||
save_state();
|
|
||||||
xTaskCreate(play_task,"dm_play",6144,NULL,5,&G.play_handle);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void select_ep(int idx){
|
|
||||||
if(idx<0||idx>=G.ep_cnt) return;
|
|
||||||
G.cur_ep_idx=idx; EpInfo* ep=&G.eps[idx]; G.cur_season=ep->season;
|
|
||||||
if(G.lbl_title) lv_label_set_text(G.lbl_title,ep->title);
|
|
||||||
if(G.lbl_meta){ char b[48]; snprintf(b,sizeof(b),"S%02d E%02d %s",ep->season,ep->ep_num,ep->seen?"*":""); lv_label_set_text(G.lbl_meta,b); }
|
|
||||||
if(G.lbl_status) lv_label_set_text(G.lbl_status,ep->seen?"On SD":"Tap Play to DL");
|
|
||||||
if(G.bar) lv_bar_set_value(G.bar,0,LV_ANIM_OFF);
|
|
||||||
if(G.lbl_time) lv_label_set_text(G.lbl_time,"0:00 / --:--");
|
|
||||||
G.pos_sec=0; save_state();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void go_next(){ if(G.ep_cnt==0) return; int n=G.cur_ep_idx+1; if(n>=G.ep_cnt) n=0; select_ep(n); start_idx(n); }
|
|
||||||
static void go_prev(){ if(G.ep_cnt==0) return; if(G.pos_sec>5){ G.pos_sec=0; return; } int p=G.cur_ep_idx-1; if(p<0) p=G.ep_cnt-1; select_ep(p); start_idx(p); }
|
|
||||||
static void fmt_time(char* o,int s){ snprintf(o,16,"%d:%02d",s/60,s%60); }
|
|
||||||
|
|
||||||
static void ui_timer(lv_timer_t* t){
|
|
||||||
(void)t;
|
|
||||||
if(G.bar){
|
|
||||||
if(G.total_sec>0){ int pct=G.pos_sec*100/G.total_sec; if(pct>100) pct=100; lv_bar_set_value(G.bar,pct,LV_ANIM_OFF); }
|
|
||||||
else if(G.is_playing){ lv_bar_set_value(G.bar,(G.pos_sec%10)*10,LV_ANIM_OFF); }
|
|
||||||
}
|
|
||||||
if(G.lbl_time){
|
|
||||||
char a[16],b[16]; fmt_time(a,G.pos_sec); if(G.total_sec>0) fmt_time(b,G.total_sec); else snprintf(b,sizeof(b),"--:--");
|
|
||||||
char buf[40]; snprintf(buf,sizeof(buf),"%s / %s",a,b); lv_label_set_text(G.lbl_time,buf);
|
|
||||||
}
|
|
||||||
if(G.is_playing && G.lbl_status) lv_label_set_text(G.lbl_status,"Playing");
|
|
||||||
}
|
|
||||||
|
|
||||||
static void btn_play_cb(lv_event_t* e){
|
|
||||||
(void)e;
|
|
||||||
if(G.is_playing && !G.is_paused){
|
|
||||||
G.is_paused=true;
|
|
||||||
if(G.lbl_status) lv_label_set_text(G.lbl_status,"Paused");
|
|
||||||
if(G.btn_play){ lv_obj_t* ch=lv_obj_get_child(G.btn_play,0); if(ch) lv_label_set_text(ch,LV_SYMBOL_PLAY);}
|
|
||||||
} else if(G.is_paused){
|
|
||||||
G.is_paused=false;
|
|
||||||
if(G.btn_play){ lv_obj_t* ch=lv_obj_get_child(G.btn_play,0); if(ch) lv_label_set_text(ch,LV_SYMBOL_PAUSE);}
|
|
||||||
} else {
|
|
||||||
start_idx(G.cur_ep_idx);
|
|
||||||
if(G.btn_play){ lv_obj_t* ch=lv_obj_get_child(G.btn_play,0); if(ch) lv_label_set_text(ch,LV_SYMBOL_PAUSE);}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
static void btn_prev_cb(lv_event_t* e){ (void)e; go_prev(); }
|
|
||||||
static void btn_next_cb(lv_event_t* e){ (void)e; go_next(); }
|
|
||||||
static void close_overlay(){ if(G.overlay){ lv_obj_del(G.overlay); G.overlay=NULL; G.roller=NULL; } }
|
|
||||||
static void roller_cb(lv_event_t* e){
|
|
||||||
if(lv_event_get_code(e)!=LV_EVENT_VALUE_CHANGED) return;
|
|
||||||
int sel=lv_roller_get_selected(G.roller); if(sel<0||sel>=G.season_cnt) return;
|
|
||||||
int sn=G.seasons[sel].num; close_overlay();
|
|
||||||
int first=-1, first_unseen=-1;
|
|
||||||
for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==sn){ if(first==-1) first=i; if(!G.eps[i].seen && first_unseen==-1) first_unseen=i; }
|
|
||||||
int tgt=first_unseen!=-1?first_unseen:first;
|
|
||||||
if(tgt!=-1){ G.cur_season=sn; select_ep(tgt); }
|
|
||||||
}
|
|
||||||
static void btn_seasons_cb(lv_event_t* e){
|
|
||||||
(void)e; if(G.overlay){ close_overlay(); return; }
|
|
||||||
G.overlay=lv_obj_create(lv_scr_act()); lv_obj_set_size(G.overlay,240,280); lv_obj_center(G.overlay);
|
|
||||||
lv_obj_set_style_bg_color(G.overlay,lv_color_hex(0x222222),0); lv_obj_set_style_radius(G.overlay,12,0);
|
|
||||||
lv_obj_t* title=lv_label_create(G.overlay); lv_label_set_text(title,"Select Season"); lv_obj_align(title,LV_ALIGN_TOP_MID,0,8);
|
|
||||||
static char opts[1024]; opts[0]=0;
|
|
||||||
for(int i=0;i<G.season_cnt;i++){ if(i==0) snprintf(opts,sizeof(opts),"S%d",G.seasons[i].num); else { char c[16]; snprintf(c,sizeof(c),"\nS%d",G.seasons[i].num); strncat(opts,c,sizeof(opts)-strlen(opts)-1);} }
|
|
||||||
G.roller=lv_roller_create(G.overlay); lv_roller_set_options(G.roller,opts,LV_ROLLER_MODE_NORMAL);
|
|
||||||
lv_obj_set_size(G.roller,120,180); lv_obj_align(G.roller,LV_ALIGN_CENTER,0,10);
|
|
||||||
int sel=0; for(int i=0;i<G.season_cnt;i++) if(G.seasons[i].num==G.cur_season) sel=i;
|
|
||||||
lv_roller_set_selected(G.roller,sel,LV_ANIM_OFF); lv_obj_add_event_cb(G.roller,roller_cb,LV_EVENT_VALUE_CHANGED,NULL);
|
|
||||||
lv_obj_t* bc=lv_btn_create(G.overlay); lv_obj_set_size(bc,80,32); lv_obj_align(bc,LV_ALIGN_BOTTOM_MID,0,-8);
|
|
||||||
lv_obj_t* lc=lv_label_create(bc); lv_label_set_text(lc,"Close"); lv_obj_center(lc);
|
|
||||||
lv_obj_add_event_cb(bc,(lv_event_cb_t)close_overlay,LV_EVENT_CLICKED,NULL);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void build_ui(){
|
|
||||||
lv_obj_t* root=lv_obj_create(lv_scr_act()); lv_obj_set_size(root,LV_PCT(100),LV_PCT(100));
|
|
||||||
lv_obj_set_style_bg_color(root,lv_color_hex(0x111111),0); lv_obj_set_style_pad_all(root,8,0); lv_obj_set_style_border_width(root,0,0);
|
|
||||||
G.lbl_title=lv_label_create(root); lv_label_set_text(G.lbl_title,"Discovery Mountain"); lv_obj_set_style_text_font(G.lbl_title,&lv_font_montserrat_14,0); lv_obj_set_width(G.lbl_title,LV_PCT(100)); lv_label_set_long_mode(G.lbl_title,LV_LABEL_LONG_WRAP); lv_obj_align(G.lbl_title,LV_ALIGN_TOP_LEFT,0,0);
|
|
||||||
G.lbl_meta=lv_label_create(root); lv_label_set_text(G.lbl_meta,"S01 E01"); lv_obj_set_style_text_color(G.lbl_meta,lv_color_hex(0xAAAAAA),0); lv_obj_align(G.lbl_meta,LV_ALIGN_TOP_LEFT,0,22);
|
|
||||||
G.bar=lv_bar_create(root); lv_obj_set_size(G.bar,LV_PCT(100),12); lv_obj_align(G.bar,LV_ALIGN_TOP_LEFT,0,44); lv_bar_set_value(G.bar,0,LV_ANIM_OFF);
|
|
||||||
G.lbl_time=lv_label_create(root); lv_label_set_text(G.lbl_time,"0:00 / --:--"); lv_obj_align(G.lbl_time,LV_ALIGN_TOP_LEFT,0,60);
|
|
||||||
G.lbl_status=lv_label_create(root); lv_label_set_text(G.lbl_status,"Ready"); lv_obj_set_style_text_color(G.lbl_status,lv_color_hex(0x888888),0); lv_obj_align(G.lbl_status,LV_ALIGN_TOP_LEFT,0,76);
|
|
||||||
lv_obj_t* row=lv_obj_create(root); lv_obj_set_size(row,LV_PCT(100),56); lv_obj_align(row,LV_ALIGN_TOP_LEFT,0,98); lv_obj_set_flex_flow(row,LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(row,LV_FLEX_ALIGN_SPACE_EVENLY,LV_FLEX_ALIGN_CENTER,LV_FLEX_ALIGN_CENTER); lv_obj_set_style_bg_opa(row,LV_OPA_TRANSP,0); lv_obj_set_style_border_width(row,0,0);
|
|
||||||
lv_obj_t* b1=lv_btn_create(row); lv_obj_set_size(b1,56,48); lv_obj_t* l1=lv_label_create(b1); lv_label_set_text(l1,LV_SYMBOL_PREV); lv_obj_center(l1); lv_obj_add_event_cb(b1,btn_prev_cb,LV_EVENT_CLICKED,NULL);
|
|
||||||
G.btn_play=lv_btn_create(row); lv_obj_set_size(G.btn_play,72,48); lv_obj_set_style_bg_color(G.btn_play,lv_color_hex(0x2E7D32),0); lv_obj_t* l2=lv_label_create(G.btn_play); lv_label_set_text(l2,LV_SYMBOL_PLAY); lv_obj_center(l2); lv_obj_add_event_cb(G.btn_play,btn_play_cb,LV_EVENT_CLICKED,NULL);
|
|
||||||
lv_obj_t* b3=lv_btn_create(row); lv_obj_set_size(b3,56,48); lv_obj_t* l3=lv_label_create(b3); lv_label_set_text(l3,LV_SYMBOL_NEXT); lv_obj_center(l3); lv_obj_add_event_cb(b3,btn_next_cb,LV_EVENT_CLICKED,NULL);
|
|
||||||
lv_obj_t* row2=lv_obj_create(root); lv_obj_set_size(row2,LV_PCT(100),44); lv_obj_align(row2,LV_ALIGN_BOTTOM_LEFT,0,0); lv_obj_set_flex_flow(row2,LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(row2,LV_FLEX_ALIGN_SPACE_EVENLY,LV_FLEX_ALIGN_CENTER,LV_FLEX_ALIGN_CENTER); lv_obj_set_style_bg_opa(row2,LV_OPA_TRANSP,0); lv_obj_set_style_border_width(row2,0,0);
|
|
||||||
lv_obj_t* bs=lv_btn_create(row2); lv_obj_set_size(bs,LV_PCT(100),36); lv_obj_t* ls=lv_label_create(bs); lv_label_set_text(ls,"Seasons"); lv_obj_center(ls); lv_obj_add_event_cb(bs,btn_seasons_cb,LV_EVENT_CLICKED,NULL);
|
|
||||||
lv_timer_create(ui_timer,500,NULL);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void onShow(AppHandle app, void* data, lv_obj_t* parent){
|
|
||||||
(void)app; (void)data; (void)parent;
|
|
||||||
memset(&G,0,0); G.pos_sec=0; G.total_sec=0; G.cur_season=1; G.cur_ep_idx=-1; G.volume=80; ensure_dir();
|
|
||||||
load_state(); fetch_data(); build_ui();
|
|
||||||
if(G.cur_ep_idx<0){ int s1=-1; for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==G.cur_season){ s1=i; break; } if(s1==-1) s1=0; G.cur_ep_idx=s1; }
|
|
||||||
if(G.ep_cnt>0 && G.cur_ep_idx>=0) select_ep(G.cur_ep_idx);
|
|
||||||
}
|
|
||||||
static void onHide(AppHandle app, void* data){ (void)app; (void)data; stop_play(); save_state(); }
|
|
||||||
|
|
||||||
int main(int argc, char* argv[]){
|
|
||||||
(void)argc; (void)argv;
|
|
||||||
tt_app_register((AppRegistration){ .onShow=onShow, .onHide=onHide });
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +0,0 @@
|
|||||||
[manifest]
|
|
||||||
version=0.1
|
|
||||||
[target]
|
|
||||||
sdk=0.8.0-dev
|
|
||||||
platforms=esp32s3
|
|
||||||
[app]
|
|
||||||
id=one.tactility.discoverymountain
|
|
||||||
name=Discovery Mountain
|
|
||||||
versionName=0.1.0
|
|
||||||
versionCode=1
|
|
||||||
type=user
|
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32s3,esp32p4
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.epubreader
|
||||||
platforms=esp32s3,esp32p4
|
app.version.name=0.4.0
|
||||||
[app]
|
app.version.code=4
|
||||||
id=one.tactility.epubreader
|
app.name=Epub Reader
|
||||||
versionName=0.1.0
|
app.description=Epub and text file reader. Requires PSRAM!
|
||||||
versionCode=1
|
|
||||||
name=Epub Reader
|
|
||||||
description=Epub and text file reader. Requires PSRAM!
|
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
cmake_minimum_required(VERSION 3.20)
|
cmake_minimum_required(VERSION 3.20)
|
||||||
|
|
||||||
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||||
|
|
||||||
if (DEFINED ENV{TACTILITY_SDK_PATH})
|
if (DEFINED ENV{TACTILITY_SDK_PATH})
|
||||||
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
|
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
|
||||||
else()
|
else()
|
||||||
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
|
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
|
||||||
message(WARNING "TACTILITY_SDK_PATH not set, defaulting to ${TACTILITY_SDK_PATH}")
|
message(WARNING "TACTILITY_SDK_PATH environment variable is not set, defaulting to ${TACTILITY_SDK_PATH}")
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
|
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
|
||||||
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
|
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
|
||||||
project(DiscoveryMountain)
|
|
||||||
tactility_project(DiscoveryMountain)
|
project(EspNowBridge)
|
||||||
|
tactility_project(EspNowBridge)
|
||||||
Binary file not shown.
@@ -0,0 +1,11 @@
|
|||||||
|
file(GLOB_RECURSE SOURCE_FILES
|
||||||
|
Source/*.c*
|
||||||
|
)
|
||||||
|
|
||||||
|
idf_component_register(
|
||||||
|
SRCS ${SOURCE_FILES}
|
||||||
|
# Library headers must be included directly,
|
||||||
|
# because all regular dependencies get stripped by elf_loader's cmake script
|
||||||
|
INCLUDE_DIRS ../../../Libraries/TactilityCpp/Include
|
||||||
|
REQUIRES TactilitySDK bootloader_support esp_app_format
|
||||||
|
)
|
||||||
@@ -0,0 +1,739 @@
|
|||||||
|
#include "EspNowBridge.h"
|
||||||
|
|
||||||
|
#include <tactility/device.h>
|
||||||
|
#include <tactility/drivers/wifi.h>
|
||||||
|
#include <tactility/wifi_auto_scan.h>
|
||||||
|
#include <tactility/firmware/firmware.h>
|
||||||
|
|
||||||
|
#include <tt_app.h>
|
||||||
|
#include <tt_app_fileselection.h>
|
||||||
|
#include <tt_bundle.h>
|
||||||
|
#include <tt_lock.h>
|
||||||
|
#include <tt_lvgl.h>
|
||||||
|
#include <tt_lvgl_toolbar.h>
|
||||||
|
|
||||||
|
#include <esp_app_desc.h>
|
||||||
|
#include <esp_app_format.h>
|
||||||
|
#include <esp_system.h>
|
||||||
|
|
||||||
|
#include <tactility/log.h>
|
||||||
|
|
||||||
|
#include <freertos/FreeRTOS.h>
|
||||||
|
#include <freertos/task.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cinttypes>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
static constexpr auto* TAG = "EspNowBridge";
|
||||||
|
static constexpr size_t CHUNK_SIZE = 1500;
|
||||||
|
static constexpr uint32_t TRANSPORT_WAIT_TIMEOUT_MS = 5000;
|
||||||
|
static constexpr uint32_t UPDATE_TASK_STACK_SIZE = 8192;
|
||||||
|
|
||||||
|
AutoScanPauseGuard::AutoScanPauseGuard() { wifi_auto_scan_set_paused(true); }
|
||||||
|
AutoScanPauseGuard::~AutoScanPauseGuard() { wifi_auto_scan_set_paused(false); }
|
||||||
|
|
||||||
|
// Binary partition table format (gen_esp32part.py STRUCT_FORMAT '<2sBBLL16sL'): a flat array of
|
||||||
|
// 32-byte little-endian records starting at flash offset PARTITION_TABLE_OFFSET, terminated by
|
||||||
|
// an all-0xFF entry or an MD5-checksum record (magic 0xEBEB). Not exposed as a C header by
|
||||||
|
// ESP-IDF (only the Python generator knows the format) - this is a hand-ported minimal reader,
|
||||||
|
// just enough to locate the app partition inside a merged/factory bin.
|
||||||
|
static constexpr size_t PARTITION_TABLE_OFFSET = 0x8000;
|
||||||
|
static constexpr size_t PARTITION_TABLE_MAX_ENTRIES = 128; // covers the largest partition table IDF supports (0x1000 / 32)
|
||||||
|
static constexpr uint16_t PARTITION_ENTRY_MAGIC = 0x50AA; // little-endian bytes 0xAA, 0x50
|
||||||
|
static constexpr uint16_t PARTITION_MD5_MAGIC = 0xEBEB;
|
||||||
|
static constexpr uint8_t PARTITION_TYPE_APP = 0x00;
|
||||||
|
static constexpr uint8_t PARTITION_SUBTYPE_FACTORY = 0x00;
|
||||||
|
static constexpr uint8_t PARTITION_SUBTYPE_OTA_0 = 0x10;
|
||||||
|
|
||||||
|
struct __attribute__((packed)) PartitionEntry {
|
||||||
|
uint16_t magic;
|
||||||
|
uint8_t type;
|
||||||
|
uint8_t subtype;
|
||||||
|
uint32_t offset;
|
||||||
|
uint32_t size;
|
||||||
|
char name[16];
|
||||||
|
uint32_t flags;
|
||||||
|
};
|
||||||
|
static_assert(sizeof(PartitionEntry) == 32, "partition table entry must be 32 bytes");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans the partition table embedded in a merged/factory bin (at PARTITION_TABLE_OFFSET) for
|
||||||
|
* the app partition to flash: prefers "factory" if present, otherwise the first OTA slot
|
||||||
|
* (ota_0) - matches what a real M5Stack ESP-Hosted factory image contains.
|
||||||
|
* @return true if an app partition was found, with appOffset/appSize set to its location
|
||||||
|
* within the file (these are the same as the absolute flash offsets the merged bin preserves).
|
||||||
|
*/
|
||||||
|
static bool findAppPartitionInMergedBin(FILE* file, size_t& appOffset, size_t& appSize) {
|
||||||
|
if (fseek(file, static_cast<long>(PARTITION_TABLE_OFFSET), SEEK_SET) != 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool foundFactory = false;
|
||||||
|
bool foundOta0 = false;
|
||||||
|
size_t factoryOffset = 0, factorySize = 0;
|
||||||
|
size_t ota0Offset = 0, ota0Size = 0;
|
||||||
|
|
||||||
|
for (size_t i = 0; i < PARTITION_TABLE_MAX_ENTRIES; i++) {
|
||||||
|
PartitionEntry entry;
|
||||||
|
if (fread(&entry, 1, sizeof(entry), file) != sizeof(entry)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (entry.magic == PARTITION_MD5_MAGIC) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (entry.magic != PARTITION_ENTRY_MAGIC) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (entry.type == PARTITION_TYPE_APP) {
|
||||||
|
if (entry.subtype == PARTITION_SUBTYPE_FACTORY) {
|
||||||
|
foundFactory = true;
|
||||||
|
factoryOffset = entry.offset;
|
||||||
|
factorySize = entry.size;
|
||||||
|
} else if (entry.subtype == PARTITION_SUBTYPE_OTA_0 && !foundOta0) {
|
||||||
|
foundOta0 = true;
|
||||||
|
ota0Offset = entry.offset;
|
||||||
|
ota0Size = entry.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foundFactory) {
|
||||||
|
appOffset = factoryOffset;
|
||||||
|
appSize = factorySize;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (foundOta0) {
|
||||||
|
appOffset = ota0Offset;
|
||||||
|
appSize = ota0Size;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the app image at the given file offset and extracts its version string. The actual
|
||||||
|
* transfer size used for the OTA loop is just the real remaining file size from appOffset (see
|
||||||
|
* performUpdate) - hand-computing the image's "logical" size from segment headers + checksum/
|
||||||
|
* hash padding drifts a bit short of the real length, so we just use the file size instead.
|
||||||
|
*/
|
||||||
|
static bool parseImageHeader(FILE* file, size_t appOffset, char* versionOut, size_t versionOutLen, std::string* errorOut = nullptr) {
|
||||||
|
esp_image_header_t imageHeader;
|
||||||
|
if (fseek(file, static_cast<long>(appOffset), SEEK_SET) != 0 ||
|
||||||
|
fread(&imageHeader, 1, sizeof(imageHeader), file) != sizeof(imageHeader)) {
|
||||||
|
if (errorOut != nullptr) {
|
||||||
|
*errorOut = "Failed to read image header";
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageHeader.magic != ESP_IMAGE_HEADER_MAGIC) {
|
||||||
|
if (errorOut != nullptr) {
|
||||||
|
*errorOut = "Selected file is not a valid firmware image (bad magic)";
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail fast on a wrong-chip image (e.g. an ESP32 or S3 binary picked by mistake) before
|
||||||
|
// streaming the whole file over the paced, slow bridge link - esp_hosted_slave_ota_end()
|
||||||
|
// would eventually catch this too, but only after the entire transfer already completed.
|
||||||
|
if (imageHeader.chip_id != ESP_CHIP_ID_ESP32C6) {
|
||||||
|
if (errorOut != nullptr) {
|
||||||
|
char buf[96];
|
||||||
|
snprintf(buf, sizeof(buf), "Wrong chip: image targets chip id %u, expected ESP32-C6",
|
||||||
|
(unsigned)imageHeader.chip_id);
|
||||||
|
*errorOut = buf;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_image_segment_header_t segmentHeader;
|
||||||
|
size_t firstSegmentOffset = appOffset + sizeof(imageHeader);
|
||||||
|
if (fseek(file, static_cast<long>(firstSegmentOffset), SEEK_SET) != 0 ||
|
||||||
|
fread(&segmentHeader, 1, sizeof(segmentHeader), file) != sizeof(segmentHeader)) {
|
||||||
|
if (errorOut != nullptr) {
|
||||||
|
*errorOut = "Failed to read first segment header";
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_app_desc_t appDesc;
|
||||||
|
size_t appDescOffset = appOffset + sizeof(imageHeader) + sizeof(segmentHeader);
|
||||||
|
if (fseek(file, static_cast<long>(appDescOffset), SEEK_SET) == 0 && fread(&appDesc, 1, sizeof(appDesc), file) == sizeof(appDesc)) {
|
||||||
|
strncpy(versionOut, appDesc.version, versionOutLen - 1);
|
||||||
|
versionOut[versionOutLen - 1] = '\0';
|
||||||
|
} else {
|
||||||
|
strncpy(versionOut, "unknown", versionOutLen - 1);
|
||||||
|
versionOut[versionOutLen - 1] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool getCurrentVersionString(const FirmwareOps* ops, void* ctx, char* versionOut, size_t versionOutLen) {
|
||||||
|
FirmwareInfo info = {};
|
||||||
|
if (ops == nullptr || ops->get_info(ctx, &info) != ERROR_NONE) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.name[0] != '\0') {
|
||||||
|
snprintf(versionOut, versionOutLen, "%u.%u.%u (%s)",
|
||||||
|
(unsigned)info.fw_major, (unsigned)info.fw_minor, (unsigned)info.fw_patch, info.name);
|
||||||
|
} else {
|
||||||
|
snprintf(versionOut, versionOutLen, "%u.%u.%u",
|
||||||
|
(unsigned)info.fw_major, (unsigned)info.fw_minor, (unsigned)info.fw_patch);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only slave firmware >= v2.6.0 implements esp_hosted_slave_ota_activate() - older slaves
|
||||||
|
* reject/lack the RPC entirely. Matches upstream's host_performs_slave_ota example. */
|
||||||
|
static bool activateSupported(uint32_t major, uint32_t minor) {
|
||||||
|
return (major > 2) || (major == 2 && minor > 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::atomic<EspNowBridge*> EspNowBridge::liveInstance_{nullptr};
|
||||||
|
|
||||||
|
void EspNowBridge::onCreate(AppHandle app) {
|
||||||
|
appHandle_ = app;
|
||||||
|
taskDoneSemaphore_ = xSemaphoreCreateBinary();
|
||||||
|
liveInstance_ = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
void EspNowBridge::onDestroy(AppHandle /*app*/) {
|
||||||
|
// Clear liveInstance_ first so any task still running bails out at its next liveInstance_
|
||||||
|
// check instead of continuing to touch this instance's members.
|
||||||
|
liveInstance_ = nullptr;
|
||||||
|
|
||||||
|
// Wait for any outstanding background task (OTA update, transport-wait) to actually finish -
|
||||||
|
// the app framework frees this instance shortly after onDestroy() returns, so a task that
|
||||||
|
// outlives it would dereference freed memory.
|
||||||
|
while (outstandingTasks_.load() > 0) {
|
||||||
|
if (taskDoneSemaphore_ != nullptr) {
|
||||||
|
xSemaphoreTake(taskDoneSemaphore_, pdMS_TO_TICKS(1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (taskDoneSemaphore_ != nullptr) {
|
||||||
|
vSemaphoreDelete(taskDoneSemaphore_);
|
||||||
|
taskDoneSemaphore_ = nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void EspNowBridge::refreshCurrentVersion() {
|
||||||
|
char versionStr[32];
|
||||||
|
if (getCurrentVersionString(firmwareOps_, firmwareCtx_, versionStr, sizeof(versionStr))) {
|
||||||
|
lv_label_set_text_fmt(currentVersionLabel_, "Co-processor firmware: %s", versionStr);
|
||||||
|
} else {
|
||||||
|
lv_label_set_text(currentVersionLabel_, "Co-processor firmware: unknown (link not up)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool EspNowBridge::isWifiRadioOn() {
|
||||||
|
if (wifiDevice_ == nullptr) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
WifiRadioState radioState = WIFI_RADIO_STATE_OFF;
|
||||||
|
if (wifi_get_radio_state(wifiDevice_, &radioState) != ERROR_NONE) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// ON with any station state (disconnected/pending/connected) is fine - the ESP-NOW bridge
|
||||||
|
// just needs the radio + esp_hosted transport up, not a completed AP connection.
|
||||||
|
return radioState == WIFI_RADIO_STATE_ON;
|
||||||
|
}
|
||||||
|
|
||||||
|
void EspNowBridge::refreshWifiPrompt() {
|
||||||
|
if (isWifiRadioOn()) {
|
||||||
|
lv_obj_add_flag(enableWifiButton_, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
setUpdateButtonsDisabled(false);
|
||||||
|
} else {
|
||||||
|
lv_obj_clear_flag(enableWifiButton_, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
setUpdateButtonsDisabled(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void EspNowBridge::setUpdateButtonsDisabled(bool disabled) {
|
||||||
|
if (disabled) {
|
||||||
|
lv_obj_add_state(updateButton_, LV_STATE_DISABLED);
|
||||||
|
lv_obj_add_state(updateBundledButton_, LV_STATE_DISABLED);
|
||||||
|
} else {
|
||||||
|
lv_obj_clear_state(updateButton_, LV_STATE_DISABLED);
|
||||||
|
lv_obj_clear_state(updateBundledButton_, LV_STATE_DISABLED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void EspNowBridge::setStatus(const std::string& text) {
|
||||||
|
lv_label_set_text(statusLabel_, text.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
void EspNowBridge::setProgress(int percent) {
|
||||||
|
lv_bar_set_value(progressBar_, percent, LV_ANIM_OFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
struct UiDispatchPayload {
|
||||||
|
EspNowBridge* instance;
|
||||||
|
void (*work)(EspNowBridge&, void*);
|
||||||
|
void* context;
|
||||||
|
void (*freeContext)(void*);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void EspNowBridge::dispatchToUi(void (*work)(EspNowBridge&, void*), void* context, void (*freeContext)(void*)) {
|
||||||
|
auto* payload = new UiDispatchPayload{this, work, context, freeContext};
|
||||||
|
// lv_async_call() itself is an LVGL operation and must be lock-guarded when called from a
|
||||||
|
// non-LVGL task (see tt_lvgl_lock()'s doc comment) - the OTA worker task calls dispatchToUi()
|
||||||
|
// repeatedly during the transfer, and without this lock most of those calls were silently
|
||||||
|
// racing LVGL's own task and getting lost (only the very last status update, right before
|
||||||
|
// esp_restart(), happened to land - everything else stayed stuck at "Waiting for
|
||||||
|
// co-processor link...").
|
||||||
|
bool locked = tt_lvgl_lock(TT_LVGL_DEFAULT_LOCK_TIME);
|
||||||
|
if (!locked) {
|
||||||
|
// Without the lock, lv_async_call() itself would be touching LVGL's internal timer list
|
||||||
|
// unguarded - and if it happened to still enqueue successfully, the callback below would
|
||||||
|
// later fire against `payload` after we've already freed it here. Drop the update instead.
|
||||||
|
if (freeContext != nullptr) {
|
||||||
|
freeContext(context);
|
||||||
|
}
|
||||||
|
delete payload;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lv_result_t result = lv_async_call([](void* userData) {
|
||||||
|
auto* payload = static_cast<UiDispatchPayload*>(userData);
|
||||||
|
if (EspNowBridge::liveInstance_.load() == payload->instance && payload->instance->isShown_.load()) {
|
||||||
|
payload->work(*payload->instance, payload->context);
|
||||||
|
}
|
||||||
|
if (payload->freeContext != nullptr) {
|
||||||
|
payload->freeContext(payload->context);
|
||||||
|
}
|
||||||
|
delete payload;
|
||||||
|
}, payload);
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
|
||||||
|
if (result != LV_RESULT_OK) {
|
||||||
|
if (freeContext != nullptr) {
|
||||||
|
freeContext(context);
|
||||||
|
}
|
||||||
|
delete payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
void workSetStatus(EspNowBridge& app, void* context) {
|
||||||
|
app.setStatus(*static_cast<std::string*>(context));
|
||||||
|
}
|
||||||
|
void freeString(void* context) { delete static_cast<std::string*>(context); }
|
||||||
|
|
||||||
|
void workSetProgress(EspNowBridge& app, void* context) {
|
||||||
|
app.setProgress(*static_cast<int*>(context));
|
||||||
|
}
|
||||||
|
void freeInt(void* context) { delete static_cast<int*>(context); }
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void EspNowBridge::performUpdate(const std::string& filePath) {
|
||||||
|
dispatchToUi([](EspNowBridge& app, void*) {
|
||||||
|
app.setUpdateButtonsDisabled(true);
|
||||||
|
app.setProgress(0);
|
||||||
|
app.setStatus("Waiting for co-processor link...");
|
||||||
|
}, nullptr, nullptr);
|
||||||
|
|
||||||
|
if (firmwareOps_ == nullptr) {
|
||||||
|
dispatchToUi([](EspNowBridge& app, void*) {
|
||||||
|
app.setStatus("This WiFi device has no updatable co-processor");
|
||||||
|
app.setUpdateButtonsDisabled(false);
|
||||||
|
}, nullptr, nullptr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!firmwareOps_->wait_ready(firmwareCtx_, TRANSPORT_WAIT_TIMEOUT_MS)) {
|
||||||
|
dispatchToUi([](EspNowBridge& app, void*) {
|
||||||
|
app.setStatus("Co-processor link not available - update cancelled");
|
||||||
|
app.setUpdateButtonsDisabled(false);
|
||||||
|
}, nullptr, nullptr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
FILE* file = fopen(filePath.c_str(), "rb");
|
||||||
|
if (file == nullptr) {
|
||||||
|
dispatchToUi([](EspNowBridge& app, void*) {
|
||||||
|
app.setStatus("Failed to open selected file");
|
||||||
|
app.setUpdateButtonsDisabled(false);
|
||||||
|
}, nullptr, nullptr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fseek(file, 0, SEEK_END);
|
||||||
|
long fileSizeSigned = ftell(file);
|
||||||
|
if (fileSizeSigned <= 0) {
|
||||||
|
fclose(file);
|
||||||
|
dispatchToUi([](EspNowBridge& app, void*) {
|
||||||
|
app.setStatus("Failed to determine file size");
|
||||||
|
app.setUpdateButtonsDisabled(false);
|
||||||
|
}, nullptr, nullptr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
size_t fileSize = static_cast<size_t>(fileSizeSigned);
|
||||||
|
|
||||||
|
// Support both a plain app image (starting with the app image header at offset 0) and a
|
||||||
|
// merged/factory bin (e.g. M5Stack's official ESP-Hosted factory image) - detected by whether
|
||||||
|
// a valid partition table is found at PARTITION_TABLE_OFFSET.
|
||||||
|
size_t appOffset = 0;
|
||||||
|
size_t partitionSize = 0;
|
||||||
|
bool isMergedBin = findAppPartitionInMergedBin(file, appOffset, partitionSize);
|
||||||
|
if (isMergedBin && appOffset >= fileSize) {
|
||||||
|
fclose(file);
|
||||||
|
dispatchToUi([](EspNowBridge& app, void*) {
|
||||||
|
app.setStatus("Merged bin's app partition is outside the file - selected file looks truncated");
|
||||||
|
app.setUpdateButtonsDisabled(false);
|
||||||
|
}, nullptr, nullptr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
char newVersion[32];
|
||||||
|
std::string parseError;
|
||||||
|
if (!parseImageHeader(file, appOffset, newVersion, sizeof(newVersion), &parseError)) {
|
||||||
|
fclose(file);
|
||||||
|
dispatchToUi(workSetStatus, new std::string(parseError), freeString);
|
||||||
|
dispatchToUi([](EspNowBridge& app, void*) {
|
||||||
|
app.setUpdateButtonsDisabled(false);
|
||||||
|
}, nullptr, nullptr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merged bins pad the app partition to its declared size; a plain app image is exactly as
|
||||||
|
// long as the app itself. Transfer whichever is smaller.
|
||||||
|
size_t remainingInFile = fileSize - appOffset;
|
||||||
|
size_t firmwareSize = isMergedBin ? std::min(partitionSize, remainingInFile) : remainingInFile;
|
||||||
|
|
||||||
|
std::string versionStr(newVersion);
|
||||||
|
{
|
||||||
|
char buf[64];
|
||||||
|
snprintf(buf, sizeof(buf), "Pushing firmware %s...", versionStr.c_str());
|
||||||
|
dispatchToUi(workSetStatus, new std::string(buf), freeString);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Held on the app instance (not a local variable) so it outlives this function - see
|
||||||
|
// heldAutoScanPauseGuard_'s declaration for why. Released when the host actually restarts
|
||||||
|
// (moot, since esp_restart() doesn't return) or if the update fails early below.
|
||||||
|
heldAutoScanPauseGuard_.emplace();
|
||||||
|
|
||||||
|
FirmwareUpdateRequest updateRequest = {};
|
||||||
|
updateRequest.image_size = firmwareSize;
|
||||||
|
FirmwareUpdateHandle* handle = nullptr;
|
||||||
|
if (firmwareOps_->begin(firmwareCtx_, &updateRequest, &handle) != ERROR_NONE) {
|
||||||
|
fclose(file);
|
||||||
|
heldAutoScanPauseGuard_.reset();
|
||||||
|
dispatchToUi([](EspNowBridge& app, void*) {
|
||||||
|
app.setStatus("Failed to start OTA on co-processor");
|
||||||
|
app.setUpdateButtonsDisabled(false);
|
||||||
|
}, nullptr, nullptr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fseek(file, static_cast<long>(appOffset), SEEK_SET) != 0) {
|
||||||
|
fclose(file);
|
||||||
|
firmwareOps_->abort(handle);
|
||||||
|
heldAutoScanPauseGuard_.reset();
|
||||||
|
dispatchToUi([](EspNowBridge& app, void*) {
|
||||||
|
app.setStatus("Failed to seek to firmware start");
|
||||||
|
app.setUpdateButtonsDisabled(false);
|
||||||
|
}, nullptr, nullptr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t chunk[CHUNK_SIZE];
|
||||||
|
size_t sent = 0;
|
||||||
|
bool writeFailed = false;
|
||||||
|
int lastReportedPercent = -1;
|
||||||
|
|
||||||
|
while (sent < firmwareSize) {
|
||||||
|
size_t toRead = (firmwareSize - sent > CHUNK_SIZE) ? CHUNK_SIZE : (firmwareSize - sent);
|
||||||
|
size_t actuallyRead = fread(chunk, 1, toRead, file);
|
||||||
|
if (actuallyRead != toRead) {
|
||||||
|
LOG_E(TAG, "Failed to read file at offset %zu", sent);
|
||||||
|
writeFailed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firmwareOps_->write(handle, chunk, actuallyRead) != ERROR_NONE) {
|
||||||
|
LOG_E(TAG, "firmwareOps_->write() failed at offset %zu", sent);
|
||||||
|
writeFailed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pace the transfer - esp_hosted's SDIO driver only retries a write twice with no
|
||||||
|
// backoff before giving up and restarting the host. Back-to-back chunk writes with zero
|
||||||
|
// gap were observed to saturate the bus enough to trigger a genuine SDIO timeout
|
||||||
|
// mid-transfer, not just around the post-activate reboot.
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(5));
|
||||||
|
|
||||||
|
sent += actuallyRead;
|
||||||
|
|
||||||
|
// Only touch LVGL every couple of percent, not every 1500-byte chunk - frequent
|
||||||
|
// display-bus activity during the transfer was implicated in SDIO transport crashes
|
||||||
|
// under sustained OTA write load.
|
||||||
|
int percent = (int)((sent * 100) / firmwareSize);
|
||||||
|
if (percent != lastReportedPercent) {
|
||||||
|
dispatchToUi(workSetProgress, new int(percent), freeInt);
|
||||||
|
lastReportedPercent = percent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose(file);
|
||||||
|
|
||||||
|
if (writeFailed) {
|
||||||
|
firmwareOps_->abort(handle);
|
||||||
|
heldAutoScanPauseGuard_.reset();
|
||||||
|
dispatchToUi([](EspNowBridge& app, void*) {
|
||||||
|
app.setStatus("Update failed while transferring firmware");
|
||||||
|
app.setUpdateButtonsDisabled(false);
|
||||||
|
}, nullptr, nullptr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firmwareOps_->finish(handle) != ERROR_NONE) {
|
||||||
|
heldAutoScanPauseGuard_.reset();
|
||||||
|
dispatchToUi([](EspNowBridge& app, void*) {
|
||||||
|
app.setStatus("Failed to finalize OTA on co-processor");
|
||||||
|
app.setUpdateButtonsDisabled(false);
|
||||||
|
}, nullptr, nullptr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the *currently running* (pre-update) slave version - the new image isn't running
|
||||||
|
// yet - and skip straight to the required host restart for older slaves.
|
||||||
|
FirmwareInfo runningInfo = {};
|
||||||
|
bool canActivate = firmwareOps_->get_info(firmwareCtx_, &runningInfo) == ERROR_NONE
|
||||||
|
&& activateSupported(runningInfo.fw_major, runningInfo.fw_minor);
|
||||||
|
|
||||||
|
if (canActivate) {
|
||||||
|
if (firmwareOps_->activate(firmwareCtx_) != ERROR_NONE) {
|
||||||
|
heldAutoScanPauseGuard_.reset();
|
||||||
|
dispatchToUi([](EspNowBridge& app, void*) {
|
||||||
|
app.setStatus("Failed to activate new firmware - co-processor still running old firmware");
|
||||||
|
app.setUpdateButtonsDisabled(false);
|
||||||
|
}, nullptr, nullptr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// heldAutoScanPauseGuard_ is deliberately left held (never explicitly released) - the host
|
||||||
|
// restarts itself immediately below, and there's no safe window to resume normal WiFi
|
||||||
|
// activity before that.
|
||||||
|
{
|
||||||
|
char buf[80];
|
||||||
|
if (canActivate) {
|
||||||
|
snprintf(buf, sizeof(buf), "Firmware %s activated - restarting...", versionStr.c_str());
|
||||||
|
} else {
|
||||||
|
snprintf(buf, sizeof(buf), "Firmware %s pushed - restarting to apply...", versionStr.c_str());
|
||||||
|
}
|
||||||
|
dispatchToUi(workSetStatus, new std::string(buf), freeString);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give the status message above a moment to actually be seen before the restart cuts the
|
||||||
|
// display, then restart.
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(1500));
|
||||||
|
esp_restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
void EspNowBridge::updateTaskEntry(void* arg) {
|
||||||
|
auto* self = static_cast<EspNowBridge*>(arg);
|
||||||
|
self->performUpdate(self->pendingUpdateFilePath_);
|
||||||
|
self->updateTask_ = nullptr;
|
||||||
|
if (self->outstandingTasks_.fetch_sub(1) == 1 && self->taskDoneSemaphore_ != nullptr) {
|
||||||
|
xSemaphoreGive(self->taskDoneSemaphore_);
|
||||||
|
}
|
||||||
|
vTaskDelete(nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
void EspNowBridge::startUpdateTask(const std::string& filePath) {
|
||||||
|
if (updateTask_ != nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingUpdateFilePath_ = filePath;
|
||||||
|
outstandingTasks_.fetch_add(1);
|
||||||
|
if (xTaskCreate(updateTaskEntry, "espnow_bridge_ota", UPDATE_TASK_STACK_SIZE / sizeof(StackType_t), this, tskIDLE_PRIORITY + 1, &updateTask_) != pdPASS) {
|
||||||
|
outstandingTasks_.fetch_sub(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void EspNowBridge::onUpdateButtonClicked(lv_event_t* /*event*/) {
|
||||||
|
auto* self = liveInstance_.load();
|
||||||
|
if (self == nullptr || !self->isWifiRadioOn()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self->pickFileLaunchId_ = tt_app_fileselection_start_for_existing_file();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name of the slave bridge firmware bundled in this app's assets/ folder
|
||||||
|
// lets users flash the known-good bridge firmware without needing to source/copy a
|
||||||
|
// .bin onto the SD card themselves. The SD-card picker (onUpdateButtonClicked above) stays
|
||||||
|
// available too, for factory-image downgrades or custom builds.
|
||||||
|
static constexpr auto* BUNDLED_FIRMWARE_ASSET_NAME = "espnow_bridge_slave_c6.bin";
|
||||||
|
|
||||||
|
void EspNowBridge::onUpdateBundledButtonClicked(lv_event_t* /*event*/) {
|
||||||
|
auto* self = liveInstance_.load();
|
||||||
|
if (self == nullptr || !self->isWifiRadioOn()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
char assetPath[256] = {};
|
||||||
|
size_t assetPathSize = sizeof(assetPath);
|
||||||
|
tt_app_get_assets_child_path(self->appHandle_, BUNDLED_FIRMWARE_ASSET_NAME, assetPath, &assetPathSize);
|
||||||
|
if (assetPath[0] == '\0') {
|
||||||
|
LOG_E(TAG, "Failed to resolve bundled firmware asset path");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self->startUpdateTask(assetPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
void EspNowBridge::onEnableWifiButtonClicked(lv_event_t* /*event*/) {
|
||||||
|
auto* self = liveInstance_.load();
|
||||||
|
if (self == nullptr || self->wifiDevice_ == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
device_start(self->wifiDevice_);
|
||||||
|
// start_device() allocates a fresh driver context (Platforms/platform-esp32's
|
||||||
|
// esp32_wifi.cpp), which wipes any event callback registered before the device was started -
|
||||||
|
// re-register now that it's actually running. Also refresh once directly rather than relying
|
||||||
|
// solely on the next WifiEvent, so the "WiFi on" prompt updates immediately even though the
|
||||||
|
// co-processor firmware version below isn't available yet.
|
||||||
|
wifi_add_event_callback(self->wifiDevice_, self, onWifiEvent);
|
||||||
|
self->refreshWifiPrompt();
|
||||||
|
self->refreshCurrentVersion();
|
||||||
|
|
||||||
|
// The co-processor RPC transport isn't up the instant device_start() returns - it comes up
|
||||||
|
// asynchronously (~1-2s later) - so firmwareOps_->get_info() above reliably fails right after
|
||||||
|
// enabling WiFi. Nothing else reliably re-triggers a version refresh once the transport
|
||||||
|
// actually comes up (WifiEvent only covers radio/station state, not transport readiness), so
|
||||||
|
// wait for it explicitly on a background task and refresh once it's ready.
|
||||||
|
if (self->firmwareOps_ != nullptr) {
|
||||||
|
self->outstandingTasks_.fetch_add(1);
|
||||||
|
if (xTaskCreate(waitForTransportTaskEntry, "espnow_bridge_wait", 4096 / sizeof(StackType_t), self, tskIDLE_PRIORITY + 1, nullptr) != pdPASS) {
|
||||||
|
self->outstandingTasks_.fetch_sub(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void EspNowBridge::waitForTransportTaskEntry(void* arg) {
|
||||||
|
auto* self = static_cast<EspNowBridge*>(arg);
|
||||||
|
constexpr uint32_t WAIT_TIMEOUT_MS = 10000;
|
||||||
|
// liveInstance_ must be checked before touching any member of self - if onDestroy() already
|
||||||
|
// ran, `self` may be freed, and dereferencing self->firmwareOps_ first would be a
|
||||||
|
// use-after-free even just to read the pointer.
|
||||||
|
if (liveInstance_.load() == self && self->firmwareOps_ != nullptr
|
||||||
|
&& self->firmwareOps_->wait_ready(self->firmwareCtx_, WAIT_TIMEOUT_MS)
|
||||||
|
&& liveInstance_.load() == self) {
|
||||||
|
self->dispatchToUi([](EspNowBridge& app, void*) {
|
||||||
|
app.refreshCurrentVersion();
|
||||||
|
}, nullptr, nullptr);
|
||||||
|
}
|
||||||
|
if (self->outstandingTasks_.fetch_sub(1) == 1 && self->taskDoneSemaphore_ != nullptr) {
|
||||||
|
xSemaphoreGive(self->taskDoneSemaphore_);
|
||||||
|
}
|
||||||
|
vTaskDelete(nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
void EspNowBridge::onWifiEvent(Device* /*device*/, void* callbackContext, WifiEvent /*event*/) {
|
||||||
|
auto* self = static_cast<EspNowBridge*>(callbackContext);
|
||||||
|
if (liveInstance_.load() != self) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self->dispatchToUi([](EspNowBridge& app, void*) {
|
||||||
|
app.refreshWifiPrompt();
|
||||||
|
app.refreshCurrentVersion();
|
||||||
|
}, nullptr, nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
void EspNowBridge::onShow(AppHandle app, lv_obj_t* parent) {
|
||||||
|
isShown_ = true;
|
||||||
|
|
||||||
|
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||||
|
|
||||||
|
lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, app);
|
||||||
|
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
|
||||||
|
|
||||||
|
auto* wrapper = lv_obj_create(parent);
|
||||||
|
lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT);
|
||||||
|
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
|
||||||
|
lv_obj_set_style_pad_all(wrapper, 8, LV_STATE_DEFAULT);
|
||||||
|
lv_obj_set_width(wrapper, LV_PCT(100));
|
||||||
|
lv_obj_set_flex_grow(wrapper, 1);
|
||||||
|
|
||||||
|
currentVersionLabel_ = lv_label_create(wrapper);
|
||||||
|
lv_obj_set_style_pad_bottom(currentVersionLabel_, 12, LV_STATE_DEFAULT);
|
||||||
|
|
||||||
|
enableWifiButton_ = lv_button_create(wrapper);
|
||||||
|
lv_obj_add_event_cb(enableWifiButton_, onEnableWifiButtonClicked, LV_EVENT_CLICKED, nullptr);
|
||||||
|
auto* enableWifiButtonLabel = lv_label_create(enableWifiButton_);
|
||||||
|
lv_label_set_text(enableWifiButtonLabel, "Enable WiFi (required for co-processor link)");
|
||||||
|
lv_obj_set_style_pad_bottom(enableWifiButton_, 12, LV_STATE_DEFAULT);
|
||||||
|
|
||||||
|
updateBundledButton_ = lv_button_create(wrapper);
|
||||||
|
lv_obj_add_event_cb(updateBundledButton_, onUpdateBundledButtonClicked, LV_EVENT_CLICKED, nullptr);
|
||||||
|
auto* updateBundledButtonLabel = lv_label_create(updateBundledButton_);
|
||||||
|
lv_label_set_text(updateBundledButtonLabel, "Update to bundled firmware");
|
||||||
|
lv_obj_set_style_pad_bottom(updateBundledButton_, 12, LV_STATE_DEFAULT);
|
||||||
|
|
||||||
|
updateButton_ = lv_button_create(wrapper);
|
||||||
|
lv_obj_add_event_cb(updateButton_, onUpdateButtonClicked, LV_EVENT_CLICKED, nullptr);
|
||||||
|
auto* updateButtonLabel = lv_label_create(updateButton_);
|
||||||
|
lv_label_set_text(updateButtonLabel, "Update from SD card...");
|
||||||
|
lv_obj_set_style_pad_bottom(updateButton_, 12, LV_STATE_DEFAULT);
|
||||||
|
|
||||||
|
progressBar_ = lv_bar_create(wrapper);
|
||||||
|
lv_obj_set_size(progressBar_, LV_PCT(100), LV_PCT(6));
|
||||||
|
lv_bar_set_range(progressBar_, 0, 100);
|
||||||
|
lv_bar_set_value(progressBar_, 0, LV_ANIM_OFF);
|
||||||
|
|
||||||
|
statusLabel_ = lv_label_create(wrapper);
|
||||||
|
lv_label_set_text(statusLabel_, "Ready");
|
||||||
|
|
||||||
|
wifiDevice_ = wifi_find_first_registered_device();
|
||||||
|
if (wifiDevice_ != nullptr) {
|
||||||
|
wifi_add_event_callback(wifiDevice_, this, onWifiEvent);
|
||||||
|
if (wifi_get_firmware_ops(wifiDevice_, &firmwareOps_, &firmwareCtx_) != ERROR_NONE) {
|
||||||
|
firmwareOps_ = nullptr;
|
||||||
|
firmwareCtx_ = nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshCurrentVersion();
|
||||||
|
refreshWifiPrompt();
|
||||||
|
|
||||||
|
// If an SD-card file was picked before this onShow() ran (FileSelection tears down and
|
||||||
|
// rebuilds this app's whole widget tree), perform the update now that widgets are valid
|
||||||
|
// again. The bundled-firmware button doesn't go through this path - it calls
|
||||||
|
// startUpdateTask() directly since there's no separate app launch/result round trip involved.
|
||||||
|
if (!pendingUpdateFilePath_.empty()) {
|
||||||
|
std::string path = std::move(pendingUpdateFilePath_);
|
||||||
|
pendingUpdateFilePath_.clear();
|
||||||
|
startUpdateTask(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void EspNowBridge::onHide(AppHandle /*app*/) {
|
||||||
|
isShown_ = false;
|
||||||
|
if (wifiDevice_ != nullptr) {
|
||||||
|
wifi_remove_event_callback(wifiDevice_, onWifiEvent);
|
||||||
|
wifiDevice_ = nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void EspNowBridge::onResult(AppHandle /*app*/, void* /*data*/, AppLaunchId launchId, AppResult result, BundleHandle resultData) {
|
||||||
|
if (launchId != pickFileLaunchId_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pickFileLaunchId_ = 0;
|
||||||
|
|
||||||
|
if (result == APP_RESULT_OK && resultData != nullptr) {
|
||||||
|
char pathBuf[256] = {};
|
||||||
|
if (tt_app_fileselection_get_result_path(resultData, pathBuf, sizeof(pathBuf))) {
|
||||||
|
pendingUpdateFilePath_ = pathBuf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <TactilityCpp/App.h>
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <freertos/FreeRTOS.h>
|
||||||
|
#include <freertos/task.h>
|
||||||
|
|
||||||
|
#include <lvgl.h>
|
||||||
|
|
||||||
|
#include <tactility/drivers/wifi.h>
|
||||||
|
|
||||||
|
/** RAII guard: pauses WifiService's background auto-connect scan for the guard's lifetime. See
|
||||||
|
* tactility/wifi_auto_scan.h - belt-and-suspenders measure, not sufficient on its own (see the
|
||||||
|
* REBOOT comment in EspNowBridge.cpp). */
|
||||||
|
class AutoScanPauseGuard {
|
||||||
|
public:
|
||||||
|
AutoScanPauseGuard();
|
||||||
|
~AutoScanPauseGuard();
|
||||||
|
AutoScanPauseGuard(const AutoScanPauseGuard&) = delete;
|
||||||
|
AutoScanPauseGuard& operator=(const AutoScanPauseGuard&) = delete;
|
||||||
|
};
|
||||||
|
|
||||||
|
class EspNowBridge final : public App {
|
||||||
|
public:
|
||||||
|
EspNowBridge() = default;
|
||||||
|
EspNowBridge(const EspNowBridge&) = delete;
|
||||||
|
EspNowBridge& operator=(const EspNowBridge&) = delete;
|
||||||
|
|
||||||
|
void onCreate(AppHandle app) override;
|
||||||
|
void onDestroy(AppHandle app) override;
|
||||||
|
void onShow(AppHandle app, lv_obj_t* parent) override;
|
||||||
|
void onHide(AppHandle app) override;
|
||||||
|
void onResult(AppHandle app, void* data, AppLaunchId launchId, AppResult result, BundleHandle resultData) override;
|
||||||
|
|
||||||
|
// Public so the free-function dispatchToUi() work callbacks in EspNowBridge.cpp (which run
|
||||||
|
// outside any member-function's lexical scope, unlike the inline lambdas in performUpdate())
|
||||||
|
// can call them.
|
||||||
|
void setStatus(const std::string& text);
|
||||||
|
void setProgress(int percent);
|
||||||
|
|
||||||
|
private:
|
||||||
|
AppHandle appHandle_ = nullptr;
|
||||||
|
AppLaunchId pickFileLaunchId_ = 0;
|
||||||
|
std::string pendingUpdateFilePath_;
|
||||||
|
Device* wifiDevice_ = nullptr;
|
||||||
|
|
||||||
|
// Resolved once in onShow() via wifi_get_firmware_ops() - null on a WiFi device with no
|
||||||
|
// updatable co-processor (e.g. a native, non-hosted chip). All OTA/version-query calls go
|
||||||
|
// through this generic interface, not any esp_hosted-specific API directly.
|
||||||
|
const FirmwareOps* firmwareOps_ = nullptr;
|
||||||
|
void* firmwareCtx_ = nullptr;
|
||||||
|
|
||||||
|
// Set once in onShow(), false once onHide() tears the widget tree down - checked (via
|
||||||
|
// dispatchToUi(), below) before touching any lv_obj_t*, since the OTA worker task and the
|
||||||
|
// WiFi-event callback can both outlive a hide/app-switch.
|
||||||
|
std::atomic<bool> isShown_{false};
|
||||||
|
|
||||||
|
// Only one EspNowBridge instance is ever live at a time (app loader owns a single instance
|
||||||
|
// per running app), so a single static "is this instance still current" pointer, guarded by
|
||||||
|
// an atomic, substitutes for the internal app's shared_ptr-based lifetime guard - the OTA
|
||||||
|
// worker task and dispatchToUi()'s lv_async_call closures check liveInstance_ == this before
|
||||||
|
// touching any member, instead of holding a shared_ptr to keep `this` alive.
|
||||||
|
static std::atomic<EspNowBridge*> liveInstance_;
|
||||||
|
|
||||||
|
TaskHandle_t updateTask_ = nullptr;
|
||||||
|
|
||||||
|
// Number of background tasks (updateTaskEntry, waitForTransportTaskEntry) currently running
|
||||||
|
// against this instance's members. onDestroy() must wait for this to hit 0 before returning -
|
||||||
|
// the app framework frees this instance shortly after onDestroy() returns (see Loader.cpp),
|
||||||
|
// so any task still touching `this` past that point is a use-after-free.
|
||||||
|
std::atomic<int> outstandingTasks_{0};
|
||||||
|
SemaphoreHandle_t taskDoneSemaphore_ = nullptr;
|
||||||
|
|
||||||
|
// Outlives performUpdate() deliberately, so auto-scan stays paused across the async gap
|
||||||
|
// between performUpdate() returning and the automatic restart - see performUpdate().
|
||||||
|
std::optional<AutoScanPauseGuard> heldAutoScanPauseGuard_;
|
||||||
|
|
||||||
|
lv_obj_t* currentVersionLabel_ = nullptr;
|
||||||
|
lv_obj_t* statusLabel_ = nullptr;
|
||||||
|
lv_obj_t* progressBar_ = nullptr;
|
||||||
|
lv_obj_t* updateButton_ = nullptr;
|
||||||
|
lv_obj_t* updateBundledButton_ = nullptr;
|
||||||
|
lv_obj_t* enableWifiButton_ = nullptr;
|
||||||
|
|
||||||
|
void refreshCurrentVersion();
|
||||||
|
bool isWifiRadioOn();
|
||||||
|
void refreshWifiPrompt();
|
||||||
|
/** Enables/disables both update-trigger buttons together - only one performUpdate() can run
|
||||||
|
* at a time (see updateTask_), regardless of which button started it. */
|
||||||
|
void setUpdateButtonsDisabled(bool disabled);
|
||||||
|
/** Marshal a UI-touching closure onto the LVGL task. Only ever invoked if liveInstance_ is
|
||||||
|
* still this instance (checked at dispatch time and again right before running, on the LVGL
|
||||||
|
* task) and isShown_ is true (this app's widget tree exists). */
|
||||||
|
void dispatchToUi(void (*work)(EspNowBridge&, void*), void* context, void (*freeContext)(void*));
|
||||||
|
void performUpdate(const std::string& filePath);
|
||||||
|
void startUpdateTask(const std::string& filePath);
|
||||||
|
|
||||||
|
static void updateTaskEntry(void* arg);
|
||||||
|
static void onUpdateButtonClicked(lv_event_t* event);
|
||||||
|
static void onUpdateBundledButtonClicked(lv_event_t* event);
|
||||||
|
static void onEnableWifiButtonClicked(lv_event_t* event);
|
||||||
|
static void onWifiEvent(Device* device, void* callbackContext, WifiEvent event);
|
||||||
|
static void waitForTransportTaskEntry(void* arg);
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
#include "EspNowBridge.h"
|
||||||
|
#include <TactilityCpp/App.h>
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
|
||||||
|
int main(int argc, char* argv[]) {
|
||||||
|
registerApp<EspNowBridge>();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
manifest.version=0.2
|
||||||
|
target.sdk=0.8.0-dev
|
||||||
|
target.platforms=esp32p4
|
||||||
|
app.id=one.tactility.espnowbridge
|
||||||
|
app.version.name=0.1.0
|
||||||
|
app.version.code=1
|
||||||
|
app.name=ESP-NOW Bridge
|
||||||
|
app.description=Companion app for updating P4 device C6 co-processor firmware to enable ESP-NOW bridge support.
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.gpio
|
||||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
app.version.name=0.7.0
|
||||||
[app]
|
app.version.code=7
|
||||||
id=one.tactility.gpio
|
app.name=GPIO
|
||||||
versionName=0.4.0
|
|
||||||
versionCode=4
|
|
||||||
name=GPIO
|
|
||||||
|
|||||||
+160
-152
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* @file main.c
|
* @file main.c
|
||||||
* @brief GameBoy DMG Emulator – Tactility (Peanut-GB) – I2 canvas version
|
* @brief GameBoy DMG Emulator for Tactility (Peanut-GB prototype, no audio)
|
||||||
* Fixes black screen after reboot: previous RGB565 PSRAM buffer required flush_cache handler which is NULL on ESP32S3 LVGL port.
|
*
|
||||||
* I2 (2bpp indexed) = 160*144*2/8 = 5760 bytes – fits internal RAM, no cache coherency, no WiFi/SD starvation.
|
* MIT licensed Peanut-GB core vendored in Libraries/PeanutGB/peanut_gb.h
|
||||||
* Uses lv_canvas_set_palette for 4 grays per GB_PALETTE.
|
* See app README for notes.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#include <tt_app.h>
|
#include <tt_app.h>
|
||||||
@@ -13,9 +13,11 @@
|
|||||||
#include <tt_lvgl_keyboard.h>
|
#include <tt_lvgl_keyboard.h>
|
||||||
|
|
||||||
#include <lvgl.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);
|
void lv_image_cache_drop(const void * src);
|
||||||
|
|
||||||
#include <esp_log.h>
|
#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_LOGI
|
||||||
#undef ESP_LOGW
|
#undef ESP_LOGW
|
||||||
#undef ESP_LOGE
|
#undef ESP_LOGE
|
||||||
@@ -38,23 +40,8 @@ void lv_image_cache_drop(const void * src);
|
|||||||
#include "peanut_gb.h"
|
#include "peanut_gb.h"
|
||||||
|
|
||||||
#define TAG "GameBoy"
|
#define TAG "GameBoy"
|
||||||
#define ROMS_DIR_LEGACY "/data/roms/gb"
|
#define DEFAULT_ROM_PATH "/data/roms/gb/default.gb"
|
||||||
#define DEFAULT_ROM_PATH_1 "/sdcard/Roms/GB/default.gb"
|
#define ROMS_DIR "/data/roms/gb"
|
||||||
#define DEFAULT_ROM_PATH_2 "/sdcard/roms/gb/default.gb"
|
|
||||||
#define DEFAULT_ROM_PATH_3 "/data/Roms/GB/default.gb"
|
|
||||||
#define DEFAULT_ROM_PATH_4 "/data/roms/gb/default.gb"
|
|
||||||
#define DEFAULT_ROM_PATH DEFAULT_ROM_PATH_1
|
|
||||||
#define ROMS_DIR ROMS_DIR_LEGACY
|
|
||||||
static const char* ROMS_CANDIDATE_DIRS[] = {
|
|
||||||
"/sdcard/Roms/GB",
|
|
||||||
"/sdcard/Roms",
|
|
||||||
"/sdcard/roms/gb",
|
|
||||||
"/sdcard/ROMS/GB",
|
|
||||||
"/data/Roms/GB",
|
|
||||||
"/data/roms/gb",
|
|
||||||
"/data/Roms",
|
|
||||||
ROMS_DIR_LEGACY,
|
|
||||||
};
|
|
||||||
#define MAX_ROMS 64
|
#define MAX_ROMS 64
|
||||||
#define MAX_PATH 512
|
#define MAX_PATH 512
|
||||||
#define MAX_ROM_SIZE (2 * 1024 * 1024)
|
#define MAX_ROM_SIZE (2 * 1024 * 1024)
|
||||||
@@ -62,6 +49,14 @@ static const char* ROMS_CANDIDATE_DIRS[] = {
|
|||||||
#define FRAME_H 144
|
#define FRAME_H 144
|
||||||
#define TICK_MS 16
|
#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 enum { APP_MODE_BROWSER, APP_MODE_EMU } AppMode;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
@@ -79,8 +74,8 @@ typedef struct {
|
|||||||
char rom_title[32];
|
char rom_title[32];
|
||||||
uint8_t joypad_state;
|
uint8_t joypad_state;
|
||||||
|
|
||||||
uint8_t* fb_indexed;
|
uint16_t* fb_native; /* RGB565 tightly packed 160*144, raw u16 avoids lv_color16_t bitfield */
|
||||||
lv_draw_buf_t* fb_draw_buf;
|
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* canvas;
|
||||||
lv_obj_t* root_wrapper;
|
lv_obj_t* root_wrapper;
|
||||||
lv_obj_t* browser_wrapper;
|
lv_obj_t* browser_wrapper;
|
||||||
@@ -111,7 +106,7 @@ typedef struct {
|
|||||||
bool rom_loaded;
|
bool rom_loaded;
|
||||||
uint32_t fps_frames;
|
uint32_t fps_frames;
|
||||||
int64_t fps_last_us;
|
int64_t fps_last_us;
|
||||||
uint32_t lines_drawn;
|
uint32_t lines_drawn; /* debug: should be 144 per frame */
|
||||||
} AppCtx;
|
} AppCtx;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
@@ -119,18 +114,15 @@ typedef struct {
|
|||||||
uint8_t joypad_bit;
|
uint8_t joypad_bit;
|
||||||
} BtnUserData;
|
} BtnUserData;
|
||||||
|
|
||||||
|
/* PSRAM helpers */
|
||||||
static void* alloc_psram(size_t size) {
|
static void* alloc_psram(size_t size) {
|
||||||
void* p = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
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 = heap_caps_malloc(size, MALLOC_CAP_8BIT);
|
||||||
return p;
|
if (!p) p = malloc(size);
|
||||||
}
|
|
||||||
static void* alloc_internal(size_t size) {
|
|
||||||
void* p = heap_caps_malloc(size, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
|
|
||||||
if (!p) p = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
|
||||||
if (!p) p = heap_caps_malloc(size, MALLOC_CAP_8BIT);
|
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Peanut-GB callbacks */
|
||||||
static uint8_t gb_rom_read(struct gb_s* gb, const uint_fast32_t addr) {
|
static uint8_t gb_rom_read(struct gb_s* gb, const uint_fast32_t addr) {
|
||||||
AppCtx* ctx = (AppCtx*)gb->direct.priv;
|
AppCtx* ctx = (AppCtx*)gb->direct.priv;
|
||||||
if (!ctx || !ctx->rom_data) return 0xFF;
|
if (!ctx || !ctx->rom_data) return 0xFF;
|
||||||
@@ -148,22 +140,37 @@ static void gb_cart_ram_write(struct gb_s* gb, const uint_fast32_t addr, const u
|
|||||||
if (!ctx || !ctx->cart_ram) return;
|
if (!ctx || !ctx->cart_ram) return;
|
||||||
if (addr < ctx->cart_ram_size) ctx->cart_ram[addr] = val;
|
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) { (void)gb;(void)err;(void)addr; }
|
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) {
|
static void lcd_draw_line(struct gb_s* gb, const uint8_t* pixels, const uint_fast8_t line) {
|
||||||
AppCtx* ctx = (AppCtx*)gb->direct.priv;
|
AppCtx* ctx = (AppCtx*)gb->direct.priv;
|
||||||
if (!ctx || !ctx->fb_indexed) return;
|
if (!ctx || !ctx->fb_native) return;
|
||||||
if (line >= FRAME_H) return;
|
if (line >= FRAME_H) return;
|
||||||
uint8_t* row = &ctx->fb_indexed[(line * FRAME_W) / 4];
|
uint16_t* dst = &ctx->fb_native[line * FRAME_W];
|
||||||
for (int x=0;x<FRAME_W;x++) {
|
for (int x=0;x<FRAME_W;x++) {
|
||||||
uint8_t shade = pixels[x] & 0x03;
|
uint8_t shade = pixels[x] & 0x03;
|
||||||
int byte_idx = x >> 2;
|
dst[x] = GB_PALETTE[shade];
|
||||||
int shift = 6 - ((x & 0x03) * 2);
|
|
||||||
row[byte_idx] = (uint8_t)((row[byte_idx] & ~(0x03 << shift)) | ((shade & 0x03) << shift));
|
|
||||||
}
|
}
|
||||||
ctx->lines_drawn++;
|
ctx->lines_drawn++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Save path */
|
||||||
static void get_save_path(const char* rom_path, char* out, size_t out_len) {
|
static void get_save_path(const char* rom_path, char* out, size_t out_len) {
|
||||||
if (!rom_path || !out) return;
|
if (!rom_path || !out) return;
|
||||||
strncpy(out, rom_path, out_len-1); out[out_len-1]='\0';
|
strncpy(out, rom_path, out_len-1); out[out_len-1]='\0';
|
||||||
@@ -177,15 +184,16 @@ static void load_cart_ram(AppCtx* ctx) {
|
|||||||
get_save_path(ctx->rom_path, save_path, sizeof(save_path));
|
get_save_path(ctx->rom_path, save_path, sizeof(save_path));
|
||||||
size_t save_sz=0;
|
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 (gb_get_save_size_s(&ctx->gb, &save_sz)!=0) save_sz=gb_get_save_size(&ctx->gb);
|
||||||
if (save_sz==0) return;
|
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; }
|
if (ctx->cart_ram) { heap_caps_free(ctx->cart_ram); ctx->cart_ram=NULL; }
|
||||||
ctx->cart_ram_size=save_sz;
|
ctx->cart_ram_size=save_sz;
|
||||||
ctx->cart_ram=alloc_psram(save_sz);
|
ctx->cart_ram=alloc_psram(save_sz);
|
||||||
if (!ctx->cart_ram){ ctx->cart_ram_size=0; return; }
|
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);
|
memset(ctx->cart_ram,0,save_sz);
|
||||||
FILE* f=fopen(save_path,"rb");
|
FILE* f=fopen(save_path,"rb");
|
||||||
if (!f) return;
|
if (!f){ ESP_LOGI(TAG,"No save %s",save_path); return; }
|
||||||
fread(ctx->cart_ram,1,save_sz,f); fclose(f);
|
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) {
|
static void save_cart_ram(AppCtx* ctx) {
|
||||||
if (!ctx || !ctx->cart_ram || ctx->cart_ram_size==0) return;
|
if (!ctx || !ctx->cart_ram || ctx->cart_ram_size==0) return;
|
||||||
@@ -193,20 +201,23 @@ static void save_cart_ram(AppCtx* ctx) {
|
|||||||
char save_path[MAX_PATH];
|
char save_path[MAX_PATH];
|
||||||
get_save_path(ctx->rom_path, save_path, sizeof(save_path));
|
get_save_path(ctx->rom_path, save_path, sizeof(save_path));
|
||||||
FILE* f=fopen(save_path,"wb");
|
FILE* f=fopen(save_path,"wb");
|
||||||
if (!f) return;
|
if (!f){ ESP_LOGE(TAG,"Save open fail %s",save_path); return; }
|
||||||
fwrite(ctx->cart_ram,1,ctx->cart_ram_size,f); fclose(f);
|
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) {
|
static bool load_rom_file(AppCtx* ctx, const char* path) {
|
||||||
if (!ctx || !path) return false;
|
if (!ctx || !path) return false;
|
||||||
|
ESP_LOGI(TAG,"Loading ROM %s",path);
|
||||||
FILE* f=fopen(path,"rb");
|
FILE* f=fopen(path,"rb");
|
||||||
if (!f) return false;
|
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);
|
fseek(f,0,SEEK_END); long sz=ftell(f); fseek(f,0,SEEK_SET);
|
||||||
if (sz<=0 || sz>MAX_ROM_SIZE){ fclose(f); return false; }
|
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);
|
uint8_t* buf=alloc_psram((size_t)sz);
|
||||||
if (!buf){ fclose(f); return false; }
|
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);
|
size_t read=fread(buf,1,(size_t)sz,f); fclose(f);
|
||||||
if (read!=(size_t)sz){ heap_caps_free(buf); return false; }
|
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->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; }
|
if (ctx->cart_ram){ heap_caps_free(ctx->cart_ram); ctx->cart_ram=NULL; ctx->cart_ram_size=0; }
|
||||||
@@ -216,42 +227,35 @@ static bool load_rom_file(AppCtx* ctx, const char* path) {
|
|||||||
memset(&ctx->gb,0,sizeof(ctx->gb));
|
memset(&ctx->gb,0,sizeof(ctx->gb));
|
||||||
ctx->joypad_state=0xFF;
|
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);
|
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){ heap_caps_free(ctx->rom_data); ctx->rom_data=NULL; ctx->rom_size=0; return false; }
|
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);
|
gb_init_lcd(&ctx->gb, lcd_draw_line);
|
||||||
load_cart_ram(ctx);
|
load_cart_ram(ctx);
|
||||||
gb_reset(&ctx->gb);
|
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);
|
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;
|
ctx->rom_loaded=true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
static void scan_rom_dir(AppCtx* ctx) {
|
static void scan_rom_dir(AppCtx* ctx) {
|
||||||
ctx->rom_count=0;
|
ctx->rom_count=0;
|
||||||
for (size_t d=0; d < sizeof(ROMS_CANDIDATE_DIRS)/sizeof(ROMS_CANDIDATE_DIRS[0]) && ctx->rom_count < MAX_ROMS; d++) {
|
DIR* dir=opendir(ROMS_DIR);
|
||||||
const char* dir_path = ROMS_CANDIDATE_DIRS[d];
|
if (!dir){ ESP_LOGW(TAG,"ROMS dir missing %s",ROMS_DIR); return; }
|
||||||
DIR* dir=opendir(dir_path);
|
struct dirent* ent;
|
||||||
if (!dir) continue;
|
while ((ent=readdir(dir))!=NULL && ctx->rom_count<MAX_ROMS){
|
||||||
struct dirent* ent;
|
if (ent->d_name[0]=='.') continue;
|
||||||
while ((ent=readdir(dir))!=NULL && ctx->rom_count<MAX_ROMS){
|
size_t len=strlen(ent->d_name);
|
||||||
if (ent->d_name[0]=='.') continue;
|
if (len<3) continue;
|
||||||
size_t len=strlen(ent->d_name);
|
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 (len<3) continue;
|
if (!is_gb) 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,".sgb")==0 || strcasecmp(ent->d_name+len-4,".bin")==0));
|
RomEntry* e=&ctx->roms[ctx->rom_count++];
|
||||||
if (!is_gb) continue;
|
strncpy(e->filename, ent->d_name, sizeof(e->filename)-1); e->filename[sizeof(e->filename)-1]='\0';
|
||||||
char full[MAX_PATH];
|
snprintf(e->fullpath, sizeof(e->fullpath), "%s/%s", ROMS_DIR, ent->d_name);
|
||||||
snprintf(full,sizeof(full),"%s/%s",dir_path,ent->d_name);
|
|
||||||
bool dup=false;
|
|
||||||
for(int i=0;i<ctx->rom_count;i++){
|
|
||||||
if (strcasecmp(ctx->roms[i].filename, ent->d_name)==0) { dup=true; break; }
|
|
||||||
}
|
|
||||||
if (dup) 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';
|
|
||||||
strncpy(e->fullpath, full, sizeof(e->fullpath)-1); e->fullpath[sizeof(e->fullpath)-1]='\0';
|
|
||||||
}
|
|
||||||
closedir(dir);
|
|
||||||
}
|
}
|
||||||
|
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) {
|
static void emu_timer_cb(lv_timer_t* timer) {
|
||||||
AppCtx* ctx=(AppCtx*)lv_timer_get_user_data(timer);
|
AppCtx* ctx=(AppCtx*)lv_timer_get_user_data(timer);
|
||||||
if (!ctx || !ctx->emu_running || !ctx->rom_loaded || !ctx->canvas) return;
|
if (!ctx || !ctx->emu_running || !ctx->rom_loaded || !ctx->canvas) return;
|
||||||
@@ -259,19 +263,22 @@ static void emu_timer_cb(lv_timer_t* timer) {
|
|||||||
ctx->gb.direct.joypad = ctx->joypad_state;
|
ctx->gb.direct.joypad = ctx->joypad_state;
|
||||||
gb_run_frame(&ctx->gb);
|
gb_run_frame(&ctx->gb);
|
||||||
|
|
||||||
if (ctx->lines_drawn == 0) {
|
/* Critical fix: raw buffer mutated, LVGL image cache is stale.
|
||||||
static uint32_t tick=0; tick++;
|
Without this, canvas shows whatever was first uploaded (gray/black) and FPS label keeps updating,
|
||||||
for (int y=0;y<FRAME_H;y++) {
|
giving "FPS but no game". */
|
||||||
uint8_t* row=&ctx->fb_indexed[(y*FRAME_W)/4];
|
if (ctx->fb_draw_buf) {
|
||||||
for (int x=0;x<FRAME_W;x++) {
|
/* Invalidate both D-Cache (PSRAM) and LVGL image cache */
|
||||||
int bi=x>>2; int sh=6-((x&3)*2);
|
lv_draw_buf_invalidate_cache(ctx->fb_draw_buf, NULL);
|
||||||
uint8_t shade = (uint8_t)((x + y + tick) & 0x03);
|
lv_image_cache_drop(ctx->fb_draw_buf);
|
||||||
row[bi] = (uint8_t)((row[bi] & ~(0x03<<sh)) | (shade<<sh));
|
/* 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ctx->fb_draw_buf) lv_image_cache_drop(ctx->fb_draw_buf);
|
|
||||||
lv_obj_invalidate(ctx->canvas);
|
lv_obj_invalidate(ctx->canvas);
|
||||||
|
|
||||||
ctx->fps_frames++;
|
ctx->fps_frames++;
|
||||||
@@ -280,20 +287,31 @@ static void emu_timer_cb(lv_timer_t* timer) {
|
|||||||
int64_t elapsed = now - ctx->fps_last_us;
|
int64_t elapsed = now - ctx->fps_last_us;
|
||||||
if (elapsed >= 1000000) {
|
if (elapsed >= 1000000) {
|
||||||
uint32_t fps = (uint32_t)((ctx->fps_frames * 1000000ULL) / (uint64_t)elapsed);
|
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);
|
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);
|
printf("GAMEBOY_FPS %lu LINES %lu\n", (unsigned long)fps, (unsigned long)ctx->lines_drawn);
|
||||||
ctx->fps_frames = 0;
|
ctx->fps_frames = 0;
|
||||||
ctx->fps_last_us = now;
|
ctx->fps_last_us = now;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Input helpers */
|
||||||
static void set_joypad_bit(AppCtx* ctx, uint8_t bit, bool pressed) {
|
static void set_joypad_bit(AppCtx* ctx, uint8_t bit, bool pressed) {
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
if (pressed) ctx->joypad_state &= (uint8_t)~bit;
|
if (pressed) ctx->joypad_state &= (uint8_t)~bit;
|
||||||
else ctx->joypad_state |= 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_down_cb(lv_event_t* e){
|
||||||
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); }
|
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){
|
static void key_press_cb(lv_event_t* e){
|
||||||
AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e);
|
AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e);
|
||||||
uint32_t key=lv_event_get_key(e);
|
uint32_t key=lv_event_get_key(e);
|
||||||
@@ -312,11 +330,13 @@ static void key_press_cb(lv_event_t* e){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Forward declarations for UI switching */
|
||||||
static void build_browser_ui(AppCtx* ctx, lv_obj_t* parent);
|
static void build_browser_ui(AppCtx* ctx, lv_obj_t* parent);
|
||||||
static void build_emu_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_browser(AppCtx* ctx);
|
||||||
static void switch_to_emu(AppCtx* ctx);
|
static void switch_to_emu(AppCtx* ctx);
|
||||||
|
|
||||||
|
/* ROM selection events */
|
||||||
static void rom_button_event(lv_event_t* e){
|
static void rom_button_event(lv_event_t* e){
|
||||||
AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e);
|
AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e);
|
||||||
lv_obj_t* btn=lv_event_get_target_obj(e);
|
lv_obj_t* btn=lv_event_get_target_obj(e);
|
||||||
@@ -325,37 +345,51 @@ static void rom_button_event(lv_event_t* e){
|
|||||||
ctx->selected_rom_idx=*pIdx;
|
ctx->selected_rom_idx=*pIdx;
|
||||||
if (ctx->selected_rom_idx<0 || ctx->selected_rom_idx>=ctx->rom_count) return;
|
if (ctx->selected_rom_idx<0 || ctx->selected_rom_idx>=ctx->rom_count) return;
|
||||||
const char* path=ctx->roms[ctx->selected_rom_idx].fullpath;
|
const char* path=ctx->roms[ctx->selected_rom_idx].fullpath;
|
||||||
if (load_rom_file(ctx, path)) switch_to_emu(ctx);
|
if (load_rom_file(ctx, path)){
|
||||||
else if (ctx->status_label) lv_label_set_text_fmt(ctx->status_label, "Failed: %s", ctx->roms[ctx->selected_rom_idx].filename);
|
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){
|
static void default_rom_event(lv_event_t* e){
|
||||||
AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e);
|
AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e);
|
||||||
if (load_rom_file(ctx, DEFAULT_ROM_PATH)) switch_to_emu(ctx);
|
if (load_rom_file(ctx, DEFAULT_ROM_PATH)){
|
||||||
else if (ctx->status_label) lv_label_set_text(ctx->status_label, "default.gb not found");
|
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);
|
||||||
}
|
}
|
||||||
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){
|
static void build_browser_ui(AppCtx* ctx, lv_obj_t* parent){
|
||||||
lv_obj_clean(parent);
|
lv_obj_clean(parent);
|
||||||
ctx->rom_list=NULL;
|
ctx->rom_list=NULL;
|
||||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||||
lv_obj_set_style_pad_all(parent, 4, 0);
|
lv_obj_set_style_pad_all(parent, 4, 0);
|
||||||
lv_obj_set_style_bg_color(parent, lv_color_black(), 0);
|
lv_obj_set_style_bg_color(parent, lv_color_black(), 0);
|
||||||
|
|
||||||
lv_obj_t* info=lv_label_create(parent);
|
lv_obj_t* info=lv_label_create(parent);
|
||||||
lv_label_set_text_fmt(info, "GameBoy I2 fix – Place ROMs in Roms/GB – Found %d ROMs", ctx->rom_count);
|
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_label_set_long_mode(info, LV_LABEL_LONG_WRAP);
|
||||||
lv_obj_set_width(info, LV_PCT(100));
|
lv_obj_set_width(info, LV_PCT(100));
|
||||||
lv_obj_set_style_text_color(info, lv_color_white(), 0);
|
lv_obj_set_style_text_color(info, lv_color_white(), 0);
|
||||||
|
|
||||||
ctx->status_label=lv_label_create(parent);
|
ctx->status_label=lv_label_create(parent);
|
||||||
lv_label_set_text(ctx->status_label, "Select a ROM – tap .gb in Files app also works");
|
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_set_style_text_color(ctx->status_label, lv_palette_main(LV_PALETTE_ORANGE), 0);
|
||||||
|
|
||||||
lv_obj_t* list=lv_list_create(parent);
|
lv_obj_t* list=lv_list_create(parent);
|
||||||
lv_obj_set_width(list, LV_PCT(100));
|
lv_obj_set_width(list, LV_PCT(100));
|
||||||
lv_obj_set_flex_grow(list, 1);
|
lv_obj_set_flex_grow(list, 1);
|
||||||
ctx->rom_list=list;
|
ctx->rom_list=list;
|
||||||
|
|
||||||
if (ctx->rom_count==0){
|
if (ctx->rom_count==0){
|
||||||
lv_obj_t* lbl=lv_label_create(parent);
|
lv_obj_t* lbl=lv_label_create(parent);
|
||||||
lv_label_set_text(lbl, "No ROMs. Put .gb in /sdcard/Roms/GB");
|
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);
|
lv_obj_set_style_text_color(lbl, lv_color_white(), 0);
|
||||||
} else {
|
} else {
|
||||||
for (int i=0;i<ctx->rom_count;i++){
|
for (int i=0;i<ctx->rom_count;i++){
|
||||||
@@ -365,6 +399,7 @@ static void build_browser_ui(AppCtx* ctx, lv_obj_t* parent){
|
|||||||
lv_obj_add_event_cb(btn, rom_button_event, LV_EVENT_CLICKED, ctx);
|
lv_obj_add_event_cb(btn, rom_button_event, LV_EVENT_CLICKED, ctx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lv_obj_t* default_btn=lv_btn_create(parent);
|
lv_obj_t* default_btn=lv_btn_create(parent);
|
||||||
lv_obj_set_width(default_btn, LV_PCT(100));
|
lv_obj_set_width(default_btn, LV_PCT(100));
|
||||||
lv_obj_t* dlbl=lv_label_create(default_btn);
|
lv_obj_t* dlbl=lv_label_create(default_btn);
|
||||||
@@ -414,13 +449,18 @@ static void build_emu_ui(AppCtx* ctx, lv_obj_t* parent){
|
|||||||
lv_obj_set_flex_align(canvas_cont, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
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);
|
lv_obj_remove_flag(canvas_cont, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
if (!ctx->fb_indexed){
|
if (!ctx->fb_native){
|
||||||
size_t fb_bytes = (FRAME_W * FRAME_H) / 4;
|
size_t fb_bytes=FRAME_W*FRAME_H*sizeof(uint16_t);
|
||||||
ctx->fb_indexed=(uint8_t*)alloc_internal(fb_bytes);
|
ctx->fb_native=(uint16_t*)alloc_psram(fb_bytes);
|
||||||
if (ctx->fb_indexed){ ctx->framebuffer_allocated=true; memset(ctx->fb_indexed,0,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_indexed){
|
if (ctx->fb_native){
|
||||||
lv_coord_t disp_w = lv_display_get_horizontal_resolution(NULL);
|
lv_coord_t disp_w = lv_display_get_horizontal_resolution(NULL);
|
||||||
lv_coord_t disp_h = lv_display_get_vertical_resolution(NULL);
|
lv_coord_t disp_h = lv_display_get_vertical_resolution(NULL);
|
||||||
int avail_h = disp_h - 160;
|
int avail_h = disp_h - 160;
|
||||||
@@ -430,23 +470,21 @@ static void build_emu_ui(AppCtx* ctx, lv_obj_t* parent){
|
|||||||
else if (avail_w >= FRAME_W * 2 && avail_h >= FRAME_H * 2) scale = 2;
|
else if (avail_w >= FRAME_W * 2 && avail_h >= FRAME_H * 2) scale = 2;
|
||||||
|
|
||||||
ctx->canvas = lv_canvas_create(canvas_cont);
|
ctx->canvas = lv_canvas_create(canvas_cont);
|
||||||
lv_canvas_set_buffer(ctx->canvas, ctx->fb_indexed, FRAME_W, FRAME_H, LV_COLOR_FORMAT_I2);
|
/* lv_canvas_set_buffer creates internal static_buf header from our raw ptr */
|
||||||
lv_color32_t c0 = { .red=0xFF, .green=0xFF, .blue=0xFF, .alpha=0xFF };
|
lv_canvas_set_buffer(ctx->canvas, ctx->fb_native, FRAME_W, FRAME_H, LV_COLOR_FORMAT_RGB565);
|
||||||
lv_color32_t c1 = { .red=0xAA, .green=0xAA, .blue=0xAA, .alpha=0xFF };
|
|
||||||
lv_color32_t c2 = { .red=0x55, .green=0x55, .blue=0x55, .alpha=0xFF };
|
|
||||||
lv_color32_t c3 = { .red=0x00, .green=0x00, .blue=0x00, .alpha=0xFF };
|
|
||||||
lv_canvas_set_palette(ctx->canvas, 0, c0);
|
|
||||||
lv_canvas_set_palette(ctx->canvas, 1, c1);
|
|
||||||
lv_canvas_set_palette(ctx->canvas, 2, c2);
|
|
||||||
lv_canvas_set_palette(ctx->canvas, 3, c3);
|
|
||||||
ctx->fb_draw_buf = lv_canvas_get_draw_buf(ctx->canvas);
|
ctx->fb_draw_buf = lv_canvas_get_draw_buf(ctx->canvas);
|
||||||
if (ctx->fb_draw_buf) lv_image_cache_drop(ctx->fb_draw_buf);
|
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) {
|
if (scale > 1) {
|
||||||
lv_image_set_scale(ctx->canvas, (uint32_t)(256 * scale));
|
lv_image_set_scale(ctx->canvas, (uint32_t)(256 * scale));
|
||||||
lv_image_set_pivot(ctx->canvas, FRAME_W / 2, FRAME_H / 2);
|
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_width(ctx->canvas, 1, 0);
|
||||||
lv_obj_set_style_border_color(ctx->canvas, lv_color_hex(0x00FF00), 0);
|
lv_obj_set_style_border_color(ctx->canvas, lv_color_hex(0x444444), 0);
|
||||||
lv_obj_center(ctx->canvas);
|
lv_obj_center(ctx->canvas);
|
||||||
} else {
|
} 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* 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);
|
||||||
@@ -471,6 +509,7 @@ static void build_emu_ui(AppCtx* ctx, lv_obj_t* parent){
|
|||||||
lv_obj_set_style_bg_opa(dpad,LV_OPA_TRANSP,0);
|
lv_obj_set_style_bg_opa(dpad,LV_OPA_TRANSP,0);
|
||||||
lv_obj_set_style_border_width(dpad,0,0);
|
lv_obj_set_style_border_width(dpad,0,0);
|
||||||
lv_obj_set_style_pad_all(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* 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* 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* right=lv_btn_create(dpad); lv_obj_set_size(right,32,32); lv_obj_set_pos(right,64,32);
|
||||||
@@ -479,6 +518,7 @@ static void build_emu_ui(AppCtx* ctx, lv_obj_t* parent){
|
|||||||
lbl=lv_label_create(down); lv_label_set_text(lbl, LV_SYMBOL_DOWN); 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(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);
|
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;
|
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[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[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]);
|
||||||
@@ -492,6 +532,7 @@ static void build_emu_ui(AppCtx* ctx, lv_obj_t* parent){
|
|||||||
lv_obj_set_flex_flow(center_col, LV_FLEX_FLOW_COLUMN);
|
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_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_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* 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);
|
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;
|
ctx->btn_select=sel_btn; ctx->btn_start=sta_btn;
|
||||||
@@ -545,6 +586,7 @@ static void switch_to_emu(AppCtx* ctx){
|
|||||||
build_emu_ui(ctx, ctx->emu_wrapper);
|
build_emu_ui(ctx, ctx->emu_wrapper);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Lifecycle */
|
||||||
static void* create_data(void){
|
static void* create_data(void){
|
||||||
AppCtx* ctx=(AppCtx*)calloc(1,sizeof(AppCtx));
|
AppCtx* ctx=(AppCtx*)calloc(1,sizeof(AppCtx));
|
||||||
if (ctx){ ctx->joypad_state=0xFF; ctx->mode=APP_MODE_BROWSER; ctx->selected_rom_idx=-1; }
|
if (ctx){ ctx->joypad_state=0xFF; ctx->mode=APP_MODE_BROWSER; ctx->selected_rom_idx=-1; }
|
||||||
@@ -554,13 +596,15 @@ static void destroy_data(void* data){
|
|||||||
AppCtx* ctx=(AppCtx*)data;
|
AppCtx* ctx=(AppCtx*)data;
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
if (ctx->emu_timer) lv_timer_delete(ctx->emu_timer);
|
if (ctx->emu_timer) lv_timer_delete(ctx->emu_timer);
|
||||||
if (ctx->fb_indexed) heap_caps_free(ctx->fb_indexed);
|
if (ctx->fb_native) heap_caps_free(ctx->fb_native);
|
||||||
if (ctx->rom_data) heap_caps_free(ctx->rom_data);
|
if (ctx->rom_data) heap_caps_free(ctx->rom_data);
|
||||||
if (ctx->cart_ram) heap_caps_free(ctx->cart_ram);
|
if (ctx->cart_ram) heap_caps_free(ctx->cart_ram);
|
||||||
free(ctx);
|
free(ctx);
|
||||||
}
|
}
|
||||||
static void on_create(AppHandle app, void* data){ AppCtx* ctx=(AppCtx*)data; if (ctx) ctx->app_handle=app; }
|
static void on_create(AppHandle app, void* data){
|
||||||
static void on_destroy(AppHandle app, void* data){ (void)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){
|
static void on_show(AppHandle app, void* data, lv_obj_t* parent){
|
||||||
AppCtx* ctx=(AppCtx*)data; if (!ctx) return;
|
AppCtx* ctx=(AppCtx*)data; if (!ctx) return;
|
||||||
ctx->app_handle=app;
|
ctx->app_handle=app;
|
||||||
@@ -594,47 +638,11 @@ static void on_show(AppHandle app, void* data, lv_obj_t* parent){
|
|||||||
lv_obj_set_style_border_width(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);
|
lv_obj_add_flag(ctx->emu_wrapper, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
|
||||||
BundleHandle bundle = tt_app_get_parameters(app);
|
|
||||||
char file_param[512] = {0};
|
|
||||||
bool hasFileParam = false;
|
|
||||||
const char* file_to_try = NULL;
|
|
||||||
if (bundle && tt_bundle_opt_string(bundle, "file", file_param, sizeof(file_param))) {
|
|
||||||
hasFileParam = file_param[0] != '\0';
|
|
||||||
file_to_try = file_param;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasFileParam && file_to_try) {
|
|
||||||
char resolved[MAX_PATH];
|
|
||||||
#pragma GCC diagnostic push
|
|
||||||
#pragma GCC diagnostic ignored "-Wformat-truncation"
|
|
||||||
if (strncmp(file_to_try, "/sdcard", 7) == 0 || strncmp(file_to_try, "/data", 5) == 0) {
|
|
||||||
snprintf(resolved, sizeof(resolved), "%s", file_to_try);
|
|
||||||
} else if (file_to_try[0] == '/') {
|
|
||||||
snprintf(resolved, sizeof(resolved), "/sdcard%s", file_to_try);
|
|
||||||
} else {
|
|
||||||
char truncated_file[400];
|
|
||||||
snprintf(truncated_file, sizeof(truncated_file), "%s", file_to_try);
|
|
||||||
snprintf(resolved, sizeof(resolved), "/sdcard/%s", truncated_file);
|
|
||||||
}
|
|
||||||
#pragma GCC diagnostic pop
|
|
||||||
struct stat st;
|
|
||||||
if (stat(resolved, &st)==0 && load_rom_file(ctx, resolved)) {
|
|
||||||
build_browser_ui(ctx, ctx->browser_wrapper);
|
|
||||||
switch_to_emu(ctx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (load_rom_file(ctx, file_to_try)) {
|
|
||||||
build_browser_ui(ctx, ctx->browser_wrapper);
|
|
||||||
switch_to_emu(ctx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
scan_rom_dir(ctx);
|
scan_rom_dir(ctx);
|
||||||
const char* default_candidates[] = { DEFAULT_ROM_PATH_1, DEFAULT_ROM_PATH_2, DEFAULT_ROM_PATH_3, DEFAULT_ROM_PATH_4, ROMS_DIR_LEGACY "/default.gb" };
|
struct stat st;
|
||||||
for (size_t i=0;i<sizeof(default_candidates)/sizeof(default_candidates[0]);i++) {
|
bool default_exists=(stat(DEFAULT_ROM_PATH,&st)==0);
|
||||||
struct stat st;
|
if (default_exists){
|
||||||
if (stat(default_candidates[i], &st)==0 && load_rom_file(ctx, default_candidates[i])) {
|
if (load_rom_file(ctx, DEFAULT_ROM_PATH)){
|
||||||
build_browser_ui(ctx, ctx->browser_wrapper);
|
build_browser_ui(ctx, ctx->browser_wrapper);
|
||||||
switch_to_emu(ctx);
|
switch_to_emu(ctx);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.graphicsdemo
|
||||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
app.version.name=0.6.0
|
||||||
[app]
|
app.version.code=6
|
||||||
id=one.tactility.graphicsdemo
|
app.name=Graphics Demo
|
||||||
versionName=0.3.0
|
|
||||||
versionCode=3
|
|
||||||
name=Graphics Demo
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.helloworld
|
||||||
platforms=esp32s3
|
app.version.name=0.6.0
|
||||||
[app]
|
app.version.code=6
|
||||||
id=one.tactility.helloworld
|
app.name=Hello World
|
||||||
versionName=0.3.0
|
|
||||||
versionCode=3
|
|
||||||
name=Hello World
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.20)
|
||||||
|
|
||||||
|
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||||
|
|
||||||
|
if (DEFINED ENV{TACTILITY_SDK_PATH})
|
||||||
|
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
|
||||||
|
else()
|
||||||
|
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
|
||||||
|
message(WARNING "⚠️ TACTILITY_SDK_PATH environment variable is not set, defaulting to ${TACTILITY_SDK_PATH}")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
|
||||||
|
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
|
||||||
|
|
||||||
|
project(ImmichElias)
|
||||||
|
tactility_project(ImmichElias)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
|
||||||
|
set(CJSON_SOURCE "$ENV{IDF_PATH}/components/json/cJSON/cJSON.c")
|
||||||
|
|
||||||
|
idf_component_register(
|
||||||
|
SRCS ${SOURCE_FILES} ${CJSON_SOURCE}
|
||||||
|
INCLUDE_DIRS Source "$ENV{IDF_PATH}/components/json/cJSON"
|
||||||
|
REQUIRES TactilitySDK esp_http_client
|
||||||
|
)
|
||||||
|
target_compile_options(${COMPONENT_LIB} PRIVATE -Wno-format-truncation -Wno-unused-but-set-variable -Wno-unused-function -Wno-unused-variable)
|
||||||
@@ -0,0 +1,988 @@
|
|||||||
|
#include <tt_app.h>
|
||||||
|
#include <tt_lvgl.h>
|
||||||
|
#include <tt_lvgl_toolbar.h>
|
||||||
|
#include <tactility/lvgl_fonts.h>
|
||||||
|
|
||||||
|
#include <esp_log.h>
|
||||||
|
#include <esp_http_client.h>
|
||||||
|
#include <esp_heap_caps.h>
|
||||||
|
#include <esp_random.h>
|
||||||
|
#include <cJSON.h>
|
||||||
|
|
||||||
|
#include <string.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <sys/types.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <dirent.h>
|
||||||
|
#include <time.h>
|
||||||
|
|
||||||
|
#include <freertos/FreeRTOS.h>
|
||||||
|
#include <freertos/task.h>
|
||||||
|
|
||||||
|
static const char* TAG = "ImmichElias";
|
||||||
|
|
||||||
|
#define IMMICH_BASE_URL "http://192.168.68.110:2283"
|
||||||
|
#define IMMICH_API_KEY "tactility-elias-2af3d9c44b72f987f41afc3438c3dd740862417797a3e5a6"
|
||||||
|
#define IMMICH_PERSON_ID "cbece6a9-720f-482a-a945-c9ba5ca41fd4"
|
||||||
|
#define PERSON_NAME "Elias"
|
||||||
|
#define PERSON_COUNT_STR "10739"
|
||||||
|
|
||||||
|
#define RESIZE_PROXY_URL "http://192.168.68.110:8106"
|
||||||
|
|
||||||
|
#define MAX_PHOTOS 100
|
||||||
|
#define JSON_BUF_SIZE (128 * 1024)
|
||||||
|
#define THUMB_BUF_SIZE (512 * 1024)
|
||||||
|
|
||||||
|
#define FALLBACK_W 320
|
||||||
|
#define FALLBACK_H 240
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
char id[64];
|
||||||
|
char name[32];
|
||||||
|
char count_str[16];
|
||||||
|
} PersonInfo;
|
||||||
|
|
||||||
|
static const PersonInfo PERSONS[] = {
|
||||||
|
{"cbece6a9-720f-482a-a945-c9ba5ca41fd4", "Elias", "10739"},
|
||||||
|
{"ad40f024-c34b-4ec6-a19e-2fa157b259e5", "Grace", ""},
|
||||||
|
{"6d9e4ae6-143d-43c9-bd42-437e3edfe2df", "Alicia", ""},
|
||||||
|
{"3ddbc731-e857-473c-8226-8af97e5cb86e", "Adolfo", ""},
|
||||||
|
};
|
||||||
|
#define PERSONS_COUNT (sizeof(PERSONS)/sizeof(PERSONS[0]))
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
char id[64];
|
||||||
|
char thumbnail_url[256];
|
||||||
|
char fileCreatedAt[64];
|
||||||
|
char originalFileName[128];
|
||||||
|
char type[16];
|
||||||
|
int width;
|
||||||
|
int height;
|
||||||
|
} Photo;
|
||||||
|
|
||||||
|
static Photo photos[MAX_PHOTOS];
|
||||||
|
static int photo_count = 0;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint8_t* buf;
|
||||||
|
size_t cap;
|
||||||
|
size_t len;
|
||||||
|
} HttpBuf;
|
||||||
|
|
||||||
|
static esp_err_t http_evt_handler(esp_http_client_event_t* evt) {
|
||||||
|
HttpBuf* hb = (HttpBuf*)evt->user_data;
|
||||||
|
if (hb == NULL) return ESP_OK;
|
||||||
|
if (evt->event_id == HTTP_EVENT_ON_DATA && evt->data && evt->data_len > 0) {
|
||||||
|
size_t copy_len = (size_t)evt->data_len;
|
||||||
|
if (hb->len + copy_len > hb->cap) {
|
||||||
|
size_t remaining = (hb->cap > hb->len) ? hb->cap - hb->len : 0;
|
||||||
|
if (remaining < copy_len) copy_len = remaining;
|
||||||
|
}
|
||||||
|
if (copy_len > 0) {
|
||||||
|
memcpy(hb->buf + hb->len, evt->data, copy_len);
|
||||||
|
hb->len += copy_len;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int http_get_with_auth(const char* url, HttpBuf* out) {
|
||||||
|
if (out) out->len = 0;
|
||||||
|
esp_http_client_config_t cfg = {
|
||||||
|
.url = url,
|
||||||
|
.event_handler = http_evt_handler,
|
||||||
|
.user_data = out,
|
||||||
|
.timeout_ms = 8000,
|
||||||
|
.buffer_size = 4096,
|
||||||
|
.buffer_size_tx = 1024,
|
||||||
|
};
|
||||||
|
esp_http_client_handle_t client = esp_http_client_init(&cfg);
|
||||||
|
if (!client) return -1;
|
||||||
|
esp_http_client_set_method(client, HTTP_METHOD_GET);
|
||||||
|
esp_http_client_set_header(client, "x-api-key", IMMICH_API_KEY);
|
||||||
|
esp_err_t err = esp_http_client_perform(client);
|
||||||
|
int status = esp_http_client_get_status_code(client);
|
||||||
|
esp_http_client_cleanup(client);
|
||||||
|
return (err == ESP_OK) ? status : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int http_get_no_auth(const char* url, HttpBuf* out) {
|
||||||
|
if (out) out->len = 0;
|
||||||
|
esp_http_client_config_t cfg = {
|
||||||
|
.url = url,
|
||||||
|
.event_handler = http_evt_handler,
|
||||||
|
.user_data = out,
|
||||||
|
.timeout_ms = 8000,
|
||||||
|
.buffer_size = 4096,
|
||||||
|
.buffer_size_tx = 1024,
|
||||||
|
};
|
||||||
|
esp_http_client_handle_t client = esp_http_client_init(&cfg);
|
||||||
|
if (!client) return -1;
|
||||||
|
esp_http_client_set_method(client, HTTP_METHOD_GET);
|
||||||
|
esp_err_t err = esp_http_client_perform(client);
|
||||||
|
int status = esp_http_client_get_status_code(client);
|
||||||
|
ESP_LOGI(TAG, "GET noauth %s status %d len %d", url, status, out ? (int)out->len : -1);
|
||||||
|
esp_http_client_cleanup(client);
|
||||||
|
return (err == ESP_OK) ? status : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int http_post_json(const char* url, const char* json_body, HttpBuf* out) {
|
||||||
|
if (out) out->len = 0;
|
||||||
|
esp_http_client_config_t cfg = {
|
||||||
|
.url = url,
|
||||||
|
.event_handler = http_evt_handler,
|
||||||
|
.user_data = out,
|
||||||
|
.timeout_ms = 8000,
|
||||||
|
.buffer_size = 4096,
|
||||||
|
.buffer_size_tx = 2048,
|
||||||
|
};
|
||||||
|
esp_http_client_handle_t client = esp_http_client_init(&cfg);
|
||||||
|
if (!client) return -1;
|
||||||
|
esp_http_client_set_method(client, HTTP_METHOD_POST);
|
||||||
|
esp_http_client_set_header(client, "x-api-key", IMMICH_API_KEY);
|
||||||
|
esp_http_client_set_header(client, "Content-Type", "application/json");
|
||||||
|
esp_http_client_set_post_field(client, json_body, strlen(json_body));
|
||||||
|
esp_err_t err = esp_http_client_perform(client);
|
||||||
|
int status = esp_http_client_get_status_code(client);
|
||||||
|
esp_http_client_cleanup(client);
|
||||||
|
return (err == ESP_OK) ? status : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void shuffle_photos(void) {
|
||||||
|
if (photo_count <= 1) return;
|
||||||
|
for (int i = photo_count - 1; i > 0; i--) {
|
||||||
|
uint32_t rnd = esp_random();
|
||||||
|
int j = rnd % (i + 1);
|
||||||
|
if (i != j) {
|
||||||
|
Photo tmp = photos[i];
|
||||||
|
photos[i] = photos[j];
|
||||||
|
photos[j] = tmp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ESP_LOGI(TAG, "Photos shuffled random, count %d", photo_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int fetch_person_assets(const char* person_id) {
|
||||||
|
if (!person_id || person_id[0]=='\0') person_id = PERSONS[0].id;
|
||||||
|
char* json_buf = heap_caps_malloc(JSON_BUF_SIZE, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||||
|
if (!json_buf) json_buf = heap_caps_malloc(JSON_BUF_SIZE, MALLOC_CAP_DEFAULT | MALLOC_CAP_8BIT);
|
||||||
|
if (!json_buf) return -1;
|
||||||
|
HttpBuf hb = { .buf = (uint8_t*)json_buf, .cap = JSON_BUF_SIZE - 1, .len = 0 };
|
||||||
|
char url[256];
|
||||||
|
snprintf(url, sizeof(url), "%s/api/search/metadata", IMMICH_BASE_URL);
|
||||||
|
// Pick random page to get variety from 10k+ photos
|
||||||
|
int total_estimate = 10739;
|
||||||
|
// Try to use count from PERSONS if available
|
||||||
|
for (int pi=0; pi<PERSONS_COUNT; pi++) {
|
||||||
|
if (strcmp(PERSONS[pi].id, person_id)==0 && PERSONS[pi].count_str[0]!='\0') {
|
||||||
|
total_estimate = atoi(PERSONS[pi].count_str);
|
||||||
|
if (total_estimate<=0) total_estimate=10739;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int max_page = total_estimate / MAX_PHOTOS;
|
||||||
|
if (max_page < 1) max_page = 1;
|
||||||
|
int random_page = (esp_random() % max_page) + 1;
|
||||||
|
char body[320];
|
||||||
|
snprintf(body, sizeof(body), "{\"personIds\":[\"%s\"],\"size\":%d,\"page\":%d}", person_id, MAX_PHOTOS, random_page);
|
||||||
|
ESP_LOGI(TAG, "Fetching person %s assets page %d/%d size %d", person_id, random_page, max_page, MAX_PHOTOS);
|
||||||
|
int status = http_post_json(url, body, &hb);
|
||||||
|
if (status != 200) { heap_caps_free(json_buf); return -1; }
|
||||||
|
if (hb.len >= JSON_BUF_SIZE) hb.len = JSON_BUF_SIZE - 1;
|
||||||
|
json_buf[hb.len] = '\0';
|
||||||
|
cJSON* root = cJSON_Parse(json_buf);
|
||||||
|
heap_caps_free(json_buf);
|
||||||
|
if (!root) return -1;
|
||||||
|
cJSON* assets = cJSON_GetObjectItem(root, "assets");
|
||||||
|
cJSON* items = NULL;
|
||||||
|
if (assets) items = cJSON_GetObjectItem(assets, "items");
|
||||||
|
else { items = cJSON_GetObjectItem(root, "items"); if (!items && cJSON_IsArray(root)) items = root; }
|
||||||
|
if (!items || !cJSON_IsArray(items)) { cJSON_Delete(root); return -1; }
|
||||||
|
int n = cJSON_GetArraySize(items);
|
||||||
|
photo_count = 0;
|
||||||
|
for (int i = 0; i < n && photo_count < MAX_PHOTOS; i++) {
|
||||||
|
cJSON* it = cJSON_GetArrayItem(items, i);
|
||||||
|
if (!it) continue;
|
||||||
|
cJSON* jid = cJSON_GetObjectItem(it, "id");
|
||||||
|
if (!jid || !cJSON_IsString(jid)) continue;
|
||||||
|
// Filter only IMAGE type to avoid video that won't display
|
||||||
|
cJSON* jt_tmp = cJSON_GetObjectItem(it, "type");
|
||||||
|
if (jt_tmp && cJSON_IsString(jt_tmp)) {
|
||||||
|
if (strcmp(jt_tmp->valuestring, "IMAGE") != 0) {
|
||||||
|
ESP_LOGI(TAG, "Skipping non-IMAGE asset %s type %s", jid->valuestring, jt_tmp->valuestring);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Photo* p = &photos[photo_count];
|
||||||
|
memset(p, 0, sizeof(Photo));
|
||||||
|
strncpy(p->id, jid->valuestring, sizeof(p->id)-1);
|
||||||
|
cJSON* jw = cJSON_GetObjectItem(it, "width"); if (jw && cJSON_IsNumber(jw)) p->width = jw->valueint;
|
||||||
|
cJSON* jh = cJSON_GetObjectItem(it, "height"); if (jh && cJSON_IsNumber(jh)) p->height = jh->valueint;
|
||||||
|
cJSON* jfc = cJSON_GetObjectItem(it, "fileCreatedAt"); if (jfc && cJSON_IsString(jfc)) strncpy(p->fileCreatedAt, jfc->valuestring, sizeof(p->fileCreatedAt)-1);
|
||||||
|
cJSON* jfn = cJSON_GetObjectItem(it, "originalFileName"); if (jfn && cJSON_IsString(jfn)) strncpy(p->originalFileName, jfn->valuestring, sizeof(p->originalFileName)-1);
|
||||||
|
cJSON* jt = cJSON_GetObjectItem(it, "type"); if (jt && cJSON_IsString(jt)) strncpy(p->type, jt->valuestring, sizeof(p->type)-1);
|
||||||
|
photo_count++;
|
||||||
|
}
|
||||||
|
cJSON_Delete(root);
|
||||||
|
shuffle_photos();
|
||||||
|
return photo_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool ensure_dir_exists(const char* file_path) {
|
||||||
|
char dir[320];
|
||||||
|
strncpy(dir, file_path, sizeof(dir)-1);
|
||||||
|
dir[sizeof(dir)-1] = '\0';
|
||||||
|
char* last_slash = strrchr(dir, '/');
|
||||||
|
if (!last_slash) return true;
|
||||||
|
*last_slash = '\0';
|
||||||
|
if (strlen(dir) == 0) return true;
|
||||||
|
struct stat st;
|
||||||
|
if (stat(dir, &st) == 0 && S_ISDIR(st.st_mode)) return true;
|
||||||
|
char tmp[320]; tmp[0]='\0';
|
||||||
|
if (dir[0]=='/') strcpy(tmp, "/");
|
||||||
|
char work[320];
|
||||||
|
strncpy(work, dir, sizeof(work)-1);
|
||||||
|
work[sizeof(work)-1]='\0';
|
||||||
|
char* p = work;
|
||||||
|
if (p[0]=='/') p++;
|
||||||
|
char* start = p;
|
||||||
|
while (1) {
|
||||||
|
char* slash = strchr(start, '/');
|
||||||
|
bool last = false;
|
||||||
|
if (slash) *slash='\0'; else last=true;
|
||||||
|
if (strlen(start)>0) {
|
||||||
|
if (strlen(tmp)>0 && tmp[strlen(tmp)-1]!='/') strncat(tmp, "/", sizeof(tmp)-strlen(tmp)-1);
|
||||||
|
strncat(tmp, start, sizeof(tmp)-strlen(tmp)-1);
|
||||||
|
mkdir(tmp, 0777);
|
||||||
|
}
|
||||||
|
if (last) break;
|
||||||
|
*slash='/';
|
||||||
|
start = slash+1;
|
||||||
|
if (*start=='\0') break;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
AppHandle app;
|
||||||
|
volatile bool visible;
|
||||||
|
volatile bool stop_requested;
|
||||||
|
// Picker (persons) like Bible book selector
|
||||||
|
lv_obj_t* picker_wrapper;
|
||||||
|
lv_obj_t* lst_persons;
|
||||||
|
// Player (photos)
|
||||||
|
lv_obj_t* player_wrapper;
|
||||||
|
lv_obj_t* header_bar;
|
||||||
|
lv_obj_t* ctrl_bar;
|
||||||
|
lv_obj_t* lbl_book_title;
|
||||||
|
lv_obj_t* lbl_indicator;
|
||||||
|
lv_obj_t* img_page;
|
||||||
|
lv_obj_t* lbl_info;
|
||||||
|
lv_obj_t* lbl_status;
|
||||||
|
lv_obj_t* btn_prev;
|
||||||
|
lv_obj_t* btn_next;
|
||||||
|
lv_obj_t* btn_reload;
|
||||||
|
lv_obj_t* btn_back;
|
||||||
|
lv_timer_t* slideshow_timer;
|
||||||
|
lv_timer_t* autohide_timer;
|
||||||
|
int current_idx;
|
||||||
|
char status_text[256];
|
||||||
|
volatile bool fetch_in_progress;
|
||||||
|
volatile bool thumb_in_progress;
|
||||||
|
uint8_t* thumb_buf;
|
||||||
|
size_t thumb_len;
|
||||||
|
TaskHandle_t fetch_task;
|
||||||
|
TaskHandle_t thumb_task;
|
||||||
|
char saved_img_path[320];
|
||||||
|
volatile bool has_image;
|
||||||
|
int disp_w;
|
||||||
|
int disp_h;
|
||||||
|
int img_w;
|
||||||
|
int img_h;
|
||||||
|
// Current person
|
||||||
|
char current_person_id[64];
|
||||||
|
char current_person_name[32];
|
||||||
|
} AppCtx;
|
||||||
|
|
||||||
|
static bool get_cache_dir_path(AppCtx* ctx, char* out_dir, size_t out_size) {
|
||||||
|
if (ctx && ctx->app) {
|
||||||
|
size_t sz = out_size;
|
||||||
|
tt_app_get_user_data_child_path(ctx->app, "cache", out_dir, &sz);
|
||||||
|
if (out_dir[0]!='\0') { mkdir(out_dir, 0777); ensure_dir_exists(out_dir); return true; }
|
||||||
|
}
|
||||||
|
snprintf(out_dir, out_size, "/sdcard/tactility/immich_cache");
|
||||||
|
mkdir(out_dir, 0777);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool get_cache_path_for_asset(AppCtx* ctx, const char* assetId, char* out_path, size_t out_size) {
|
||||||
|
if (!assetId || !out_path || out_size==0) return false;
|
||||||
|
int w = ctx ? ctx->img_w : FALLBACK_W;
|
||||||
|
int h = ctx ? ctx->img_h : FALLBACK_H;
|
||||||
|
if (w<=0) w=FALLBACK_W;
|
||||||
|
if (h<=0) h=FALLBACK_H;
|
||||||
|
if (w>800) w=800;
|
||||||
|
if (h>600) h=600;
|
||||||
|
if (ctx && ctx->app) {
|
||||||
|
char child[160];
|
||||||
|
snprintf(child, sizeof(child), "cache/%s_%dx%d.png", assetId, w, h);
|
||||||
|
size_t sz = out_size;
|
||||||
|
tt_app_get_user_data_child_path(ctx->app, child, out_path, &sz);
|
||||||
|
if (out_path[0]!='\0') { ensure_dir_exists(out_path); return true; }
|
||||||
|
}
|
||||||
|
snprintf(out_path, out_size, "/sdcard/tactility/immich_cache/%s_%dx%d.png", assetId, w, h);
|
||||||
|
ensure_dir_exists(out_path);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool get_cache_path_legacy(AppCtx* ctx, const char* assetId, char* out_path, size_t out_size) {
|
||||||
|
if (!assetId || !out_path) return false;
|
||||||
|
if (ctx && ctx->app) {
|
||||||
|
char child[128];
|
||||||
|
snprintf(child, sizeof(child), "cache/%s.png", assetId);
|
||||||
|
size_t sz = out_size;
|
||||||
|
tt_app_get_user_data_child_path(ctx->app, child, out_path, &sz);
|
||||||
|
if (out_path[0]!='\0') { ensure_dir_exists(out_path); return true; }
|
||||||
|
}
|
||||||
|
snprintf(out_path, out_size, "/sdcard/tactility/immich_cache/%s.png", assetId);
|
||||||
|
ensure_dir_exists(out_path);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool get_image_save_path(AppCtx* ctx, char* out_path, size_t out_size) {
|
||||||
|
if (ctx && ctx->app) {
|
||||||
|
size_t sz = out_size;
|
||||||
|
tt_app_get_user_data_child_path(ctx->app, "immich_cur.png", out_path, &sz);
|
||||||
|
if (out_path[0]!='\0') { ensure_dir_exists(out_path); return true; }
|
||||||
|
}
|
||||||
|
snprintf(out_path, out_size, "/sdcard/immich_cur.png");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static const char* save_buffer_to_cache(AppCtx* ctx, const char* assetId, const uint8_t* buf, size_t len, char* out_saved_path, size_t out_saved_size) {
|
||||||
|
char path[320];
|
||||||
|
if (!get_cache_path_for_asset(ctx, assetId, path, sizeof(path))) return NULL;
|
||||||
|
ensure_dir_exists(path);
|
||||||
|
FILE* f = fopen(path, "wb");
|
||||||
|
if (!f) {
|
||||||
|
ESP_LOGW(TAG, "fopen fail cache %s", path);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
size_t written = fwrite(buf, 1, len, f);
|
||||||
|
fclose(f);
|
||||||
|
ESP_LOGI(TAG, "Cached %d bytes to %s written %d (req %dx%d)", (int)len, path, (int)written, ctx ? ctx->img_w : 0, ctx ? ctx->img_h : 0);
|
||||||
|
if (written != len) return NULL;
|
||||||
|
if (out_saved_path && out_saved_size>0) { strncpy(out_saved_path, path, out_saved_size-1); out_saved_path[out_saved_size-1]='\0'; }
|
||||||
|
char legacy[320];
|
||||||
|
if (get_image_save_path(ctx, legacy, sizeof(legacy))) {
|
||||||
|
FILE* lf = fopen(legacy, "wb");
|
||||||
|
if (lf) { fwrite(buf,1,len,lf); fclose(lf); }
|
||||||
|
}
|
||||||
|
return out_saved_path;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool load_from_cache(AppCtx* ctx, const char* assetId, char* out_path, size_t out_size, size_t* out_file_size) {
|
||||||
|
char path[320];
|
||||||
|
if (!get_cache_path_for_asset(ctx, assetId, path, sizeof(path))) return false;
|
||||||
|
struct stat st;
|
||||||
|
if (stat(path, &st)==0 && st.st_size>0 && st.st_size <= THUMB_BUF_SIZE) {
|
||||||
|
FILE* f = fopen(path, "rb");
|
||||||
|
if (f) { fclose(f); strncpy(out_path, path, out_size-1); out_path[out_size-1]='\0'; if(out_file_size) *out_file_size=(size_t)st.st_size; ESP_LOGI(TAG,"Cache HIT %s size %d (sized)", path, (int)st.st_size); return true; }
|
||||||
|
}
|
||||||
|
// Try legacy without size
|
||||||
|
if (!get_cache_path_legacy(ctx, assetId, path, sizeof(path))) return false;
|
||||||
|
if (stat(path, &st)==0 && st.st_size>0 && st.st_size <= THUMB_BUF_SIZE) {
|
||||||
|
FILE* f = fopen(path, "rb");
|
||||||
|
if (f) { fclose(f); strncpy(out_path, path, out_size-1); out_path[out_size-1]='\0'; if(out_file_size) *out_file_size=(size_t)st.st_size; ESP_LOGI(TAG,"Cache HIT %s size %d (legacy)", path, (int)st.st_size); return true; }
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int scan_cache_dir_for_photos(AppCtx* ctx) {
|
||||||
|
char dir_path[320];
|
||||||
|
if (!get_cache_dir_path(ctx, dir_path, sizeof(dir_path))) return 0;
|
||||||
|
DIR* d = opendir(dir_path);
|
||||||
|
if (!d) return 0;
|
||||||
|
photo_count = 0;
|
||||||
|
struct dirent* ent;
|
||||||
|
while ((ent = readdir(d))!=NULL && photo_count < MAX_PHOTOS) {
|
||||||
|
if (ent->d_name[0]=='.') continue;
|
||||||
|
size_t len = strlen(ent->d_name);
|
||||||
|
if (len<5) continue;
|
||||||
|
const char* ext = ent->d_name + len -4;
|
||||||
|
if (!(ext[0]=='.' && (ext[1]=='p'||ext[1]=='P') && (ext[2]=='n'||ext[2]=='N') && (ext[3]=='g'||ext[3]=='G'))) continue;
|
||||||
|
char id_buf[64];
|
||||||
|
strncpy(id_buf, ent->d_name, sizeof(id_buf)-1);
|
||||||
|
id_buf[sizeof(id_buf)-1]='\0';
|
||||||
|
// Strip size suffix _WxH if present
|
||||||
|
char* us = strrchr(id_buf, '_');
|
||||||
|
if (us) {
|
||||||
|
// Check if after _ is like 320x240.png
|
||||||
|
char* dot = strrchr(id_buf, '.');
|
||||||
|
if (dot) *dot='\0';
|
||||||
|
// If us contains x, truncate at _
|
||||||
|
// Actually id may contain _, but we want id part before _
|
||||||
|
// For cache files like <id>_<w>x<h>.png, we want id before _
|
||||||
|
// The id itself is uuid without _, so first _ is separator
|
||||||
|
// So keep part before _
|
||||||
|
if (us != id_buf) {
|
||||||
|
// Ensure remaining part after _ contains x
|
||||||
|
if (strchr(us, 'x') || strchr(us, 'X')) {
|
||||||
|
*us='\0';
|
||||||
|
} else {
|
||||||
|
// No x, it was legacy without size, keep as is but already stripped extension
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
char* dot2 = strrchr(id_buf, '.');
|
||||||
|
if (dot2) *dot2='\0';
|
||||||
|
}
|
||||||
|
if (strlen(id_buf)==0) continue;
|
||||||
|
// Deduplicate: check if id already exists
|
||||||
|
bool dup=false;
|
||||||
|
for (int i=0;i<photo_count;i++) if (strcmp(photos[i].id, id_buf)==0) {dup=true; break;}
|
||||||
|
if (dup) continue;
|
||||||
|
Photo* p=&photos[photo_count];
|
||||||
|
memset(p,0,sizeof(Photo));
|
||||||
|
strncpy(p->id, id_buf, sizeof(p->id)-1);
|
||||||
|
strncpy(p->originalFileName, ent->d_name, sizeof(p->originalFileName)-1);
|
||||||
|
strncpy(p->type, "IMAGE", sizeof(p->type)-1);
|
||||||
|
photo_count++;
|
||||||
|
}
|
||||||
|
closedir(d);
|
||||||
|
if (photo_count>0) shuffle_photos();
|
||||||
|
return photo_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int fetch_png_from_proxy(const char* assetId, uint8_t* buf, size_t cap, size_t* out_len, int req_w, int req_h) {
|
||||||
|
if (req_w<=0) req_w=FALLBACK_W;
|
||||||
|
if (req_h<=0) req_h=FALLBACK_H;
|
||||||
|
if (req_w>800) req_w=800;
|
||||||
|
if (req_h>600) req_h=600;
|
||||||
|
char url[384];
|
||||||
|
snprintf(url, sizeof(url), "%s/resize?assetId=%s&w=%d&h=%d&format=png", RESIZE_PROXY_URL, assetId, req_w, req_h);
|
||||||
|
ESP_LOGI(TAG, "Fetching proxy PNG %s (req %dx%d)", url, req_w, req_h);
|
||||||
|
HttpBuf hb = { .buf = buf, .cap = cap, .len = 0 };
|
||||||
|
int status = http_get_no_auth(url, &hb);
|
||||||
|
if (status==200 && hb.len>0) {
|
||||||
|
if (out_len) *out_len=hb.len;
|
||||||
|
ESP_LOGI(TAG, "Proxy PNG OK %d bytes for %dx%d", (int)hb.len, req_w, req_h);
|
||||||
|
return (int)hb.len;
|
||||||
|
}
|
||||||
|
ESP_LOGE(TAG, "Proxy PNG fail id %s %dx%d status %d", assetId, req_w, req_h, status);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void start_fetch_task(AppCtx* ctx);
|
||||||
|
static void start_thumb_task(AppCtx* ctx);
|
||||||
|
static void ui_update_all(AppCtx* ctx);
|
||||||
|
static void thumb_task_fn(void* arg);
|
||||||
|
static void fetch_task_fn(void* arg);
|
||||||
|
static void hide_chrome(AppCtx* ctx);
|
||||||
|
static void show_chrome(AppCtx* ctx);
|
||||||
|
|
||||||
|
static void detect_display_size(AppCtx* ctx, lv_obj_t* parent) {
|
||||||
|
if (!ctx) return;
|
||||||
|
int w=FALLBACK_W, h=FALLBACK_H;
|
||||||
|
lv_display_t* disp=NULL;
|
||||||
|
if (parent) disp = lv_obj_get_display(parent);
|
||||||
|
if (!disp) disp = lv_display_get_default();
|
||||||
|
if (disp) {
|
||||||
|
int dw = lv_display_get_horizontal_resolution(disp);
|
||||||
|
int dh = lv_display_get_vertical_resolution(disp);
|
||||||
|
if (dw>0 && dh>0) { w=dw; h=dh; }
|
||||||
|
}
|
||||||
|
if (w==FALLBACK_W && h==FALLBACK_H) {
|
||||||
|
int dw2 = lv_display_get_horizontal_resolution(NULL);
|
||||||
|
int dh2 = lv_display_get_vertical_resolution(NULL);
|
||||||
|
if (dw2>0 && dh2>0) { w=dw2; h=dh2; }
|
||||||
|
}
|
||||||
|
ctx->disp_w=w; ctx->disp_h=h;
|
||||||
|
// Black screen fix: 480x320 PNG decodes ~600KB, causes internal low 8723 bytes -> decoder fail -> black screen
|
||||||
|
// Use 320x240 (decoded ~300KB) for stability, widget still fullscreen 480x320 background
|
||||||
|
// 2x would be 480x320 but OOM, so keep 320x240 as best stable, or 240x180 for extra safety
|
||||||
|
int target_w = 320;
|
||||||
|
int target_h = 240;
|
||||||
|
ctx->img_w=target_w; ctx->img_h=target_h;
|
||||||
|
ESP_LOGI(TAG, "Display detected %dx%d -> img request %dx%d FULLSCREEN bg (320x240 stable, 480x320=2x OOM)", w,h,target_w,target_h);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void hide_chrome(AppCtx* ctx) {
|
||||||
|
if (!ctx) return;
|
||||||
|
if (!tt_lvgl_lock(pdMS_TO_TICKS(500))) return;
|
||||||
|
// Bible-style: only bottom controls, no top header
|
||||||
|
if (ctx->ctrl_bar) lv_obj_add_flag(ctx->ctrl_bar, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
}
|
||||||
|
static void show_chrome(AppCtx* ctx) {
|
||||||
|
if (!ctx) return;
|
||||||
|
if (!tt_lvgl_lock(pdMS_TO_TICKS(500))) return;
|
||||||
|
if (ctx->ctrl_bar) lv_obj_remove_flag(ctx->ctrl_bar, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
if (tt_lvgl_lock(pdMS_TO_TICKS(300))) {
|
||||||
|
if (ctx->autohide_timer) { lv_timer_reset(ctx->autohide_timer); lv_timer_resume(ctx->autohide_timer); }
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
static void autohide_timer_cb(lv_timer_t* timer) {
|
||||||
|
AppCtx* ctx = (AppCtx*)lv_timer_get_user_data(timer);
|
||||||
|
if (!ctx || !ctx->visible || ctx->stop_requested) return;
|
||||||
|
hide_chrome(ctx);
|
||||||
|
if (tt_lvgl_lock(pdMS_TO_TICKS(200))) { lv_timer_pause(timer); tt_lvgl_unlock(); }
|
||||||
|
}
|
||||||
|
static void slideshow_timer_cb(lv_timer_t* timer) {
|
||||||
|
AppCtx* ctx = (AppCtx*)lv_timer_get_user_data(timer);
|
||||||
|
if (!ctx || !ctx->visible || ctx->stop_requested) return;
|
||||||
|
if (ctx->fetch_in_progress || ctx->thumb_in_progress) return;
|
||||||
|
if (photo_count==0) return;
|
||||||
|
ESP_LOGI(TAG, "Slideshow auto-advance 60s %dx%d", ctx->img_w, ctx->img_h);
|
||||||
|
ctx->current_idx++;
|
||||||
|
if (ctx->current_idx >= photo_count) ctx->current_idx=0;
|
||||||
|
ctx->thumb_len=0; ctx->has_image=false;
|
||||||
|
snprintf(ctx->status_text, sizeof(ctx->status_text), "Slideshow %d/%d %dx%d", ctx->current_idx+1, photo_count, ctx->img_w, ctx->img_h);
|
||||||
|
ui_update_all(ctx);
|
||||||
|
show_chrome(ctx);
|
||||||
|
start_thumb_task(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ui_update_all(AppCtx* ctx) {
|
||||||
|
if (!ctx || !ctx->visible || ctx->stop_requested) return;
|
||||||
|
if (!tt_lvgl_lock(pdMS_TO_TICKS(1000))) return;
|
||||||
|
if (!ctx->visible || ctx->stop_requested) { tt_lvgl_unlock(); return; }
|
||||||
|
if (ctx->lbl_book_title) lv_label_set_text_fmt(ctx->lbl_book_title, "%s - %s photos", PERSON_NAME, PERSON_COUNT_STR);
|
||||||
|
if (ctx->lbl_indicator) {
|
||||||
|
if (photo_count>0) lv_label_set_text_fmt(ctx->lbl_indicator, "%d/%d", ctx->current_idx+1, photo_count);
|
||||||
|
else lv_label_set_text(ctx->lbl_indicator, "0/0");
|
||||||
|
}
|
||||||
|
if (ctx->lbl_info) {
|
||||||
|
if (photo_count>0 && ctx->current_idx < photo_count) {
|
||||||
|
Photo* p=&photos[ctx->current_idx];
|
||||||
|
lv_label_set_text_fmt(ctx->lbl_info, "%s\n%.24s\n%dx%d disp %dx%d img %dx%d", p->originalFileName, p->fileCreatedAt, p->width, p->height, ctx->disp_w, ctx->disp_h, ctx->img_w, ctx->img_h);
|
||||||
|
} else lv_label_set_text(ctx->lbl_info, ctx->status_text);
|
||||||
|
}
|
||||||
|
if (ctx->lbl_status) {
|
||||||
|
if (ctx->thumb_len>0 && ctx->has_image) lv_label_set_text_fmt(ctx->lbl_status, "PNG %d bytes %dx%d", (int)ctx->thumb_len, ctx->img_w, ctx->img_h);
|
||||||
|
else lv_label_set_text(ctx->lbl_status, ctx->status_text);
|
||||||
|
}
|
||||||
|
if (ctx->img_page) {
|
||||||
|
if (ctx->has_image && ctx->saved_img_path[0]!='\0') {
|
||||||
|
FILE* test = fopen(ctx->saved_img_path, "rb");
|
||||||
|
if (test) {
|
||||||
|
fclose(test);
|
||||||
|
char lvgl_path[384];
|
||||||
|
snprintf(lvgl_path, sizeof(lvgl_path), "A:%s", ctx->saved_img_path);
|
||||||
|
lv_image_set_src(ctx->img_page, lvgl_path);
|
||||||
|
lv_obj_clear_flag(ctx->img_page, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
// Fullscreen background: widget covers entire display
|
||||||
|
lv_obj_set_size(ctx->img_page, ctx->disp_w > 0 ? ctx->disp_w : ctx->img_w, ctx->disp_h > 0 ? ctx->disp_h : ctx->img_h);
|
||||||
|
lv_obj_center(ctx->img_page);
|
||||||
|
ESP_LOGI(TAG, "lv_img_set_src %s %dx%d", lvgl_path, ctx->disp_w, ctx->disp_h);
|
||||||
|
} else {
|
||||||
|
ESP_LOGW(TAG, "Image file not found for display: %s", ctx->saved_img_path);
|
||||||
|
lv_image_set_src(ctx->img_page, LV_SYMBOL_IMAGE);
|
||||||
|
lv_obj_clear_flag(ctx->img_page, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (photo_count==0) {
|
||||||
|
lv_image_set_src(ctx->img_page, LV_SYMBOL_IMAGE);
|
||||||
|
lv_obj_clear_flag(ctx->img_page, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
} else {
|
||||||
|
// Keep previous image to avoid black flash, or hide if you prefer
|
||||||
|
// lv_obj_add_flag(ctx->img_page, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void thumb_task_fn(void* arg) {
|
||||||
|
AppCtx* ctx = (AppCtx*)arg;
|
||||||
|
if (!ctx) { vTaskDelete(NULL); return; }
|
||||||
|
if (ctx->stop_requested) { ctx->thumb_task=NULL; ctx->thumb_in_progress=false; vTaskDelete(NULL); return; }
|
||||||
|
if (!ctx->thumb_buf) {
|
||||||
|
ctx->thumb_buf = heap_caps_malloc(THUMB_BUF_SIZE, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||||
|
if (!ctx->thumb_buf) ctx->thumb_buf = heap_caps_malloc(THUMB_BUF_SIZE, MALLOC_CAP_DEFAULT | MALLOC_CAP_8BIT);
|
||||||
|
}
|
||||||
|
if (!ctx->thumb_buf) { snprintf(ctx->status_text, sizeof(ctx->status_text), "OOM thumb buf"); ui_update_all(ctx); ctx->thumb_task=NULL; ctx->thumb_in_progress=false; vTaskDelete(NULL); return; }
|
||||||
|
int idx = ctx->current_idx;
|
||||||
|
if (idx<0 || idx>=photo_count) idx=0;
|
||||||
|
Photo* p=&photos[idx];
|
||||||
|
char cache_path[320]={0}; size_t cached_size=0;
|
||||||
|
if (load_from_cache(ctx, p->id, cache_path, sizeof(cache_path), &cached_size)) {
|
||||||
|
strncpy(ctx->saved_img_path, cache_path, sizeof(ctx->saved_img_path)-1);
|
||||||
|
ctx->has_image=true; ctx->thumb_len=cached_size;
|
||||||
|
snprintf(ctx->status_text, sizeof(ctx->status_text), "Cached PNG %d bytes %dx%d", (int)cached_size, ctx->img_w, ctx->img_h);
|
||||||
|
ui_update_all(ctx); show_chrome(ctx);
|
||||||
|
ctx->thumb_task=NULL; ctx->thumb_in_progress=false; vTaskDelete(NULL); return;
|
||||||
|
}
|
||||||
|
ESP_LOGI(TAG, "Downloading PNG idx %d id %s %dx%d", idx, p->id, ctx->img_w, ctx->img_h);
|
||||||
|
snprintf(ctx->status_text, sizeof(ctx->status_text), "Fetching PNG %d/%d %dx%d", idx+1, photo_count, ctx->img_w, ctx->img_h);
|
||||||
|
ui_update_all(ctx);
|
||||||
|
if (ctx->stop_requested) { ctx->thumb_task=NULL; ctx->thumb_in_progress=false; vTaskDelete(NULL); return; }
|
||||||
|
size_t out_len=0;
|
||||||
|
int r = fetch_png_from_proxy(p->id, ctx->thumb_buf, THUMB_BUF_SIZE, &out_len, ctx->img_w, ctx->img_h);
|
||||||
|
// Fallback to 320x240 if requested size fails
|
||||||
|
if (r<=0 && (ctx->img_w != 320 || ctx->img_h != 240)) {
|
||||||
|
ESP_LOGW(TAG, "Proxy fail %dx%d, trying fallback 320x240", ctx->img_w, ctx->img_h);
|
||||||
|
r = fetch_png_from_proxy(p->id, ctx->thumb_buf, THUMB_BUF_SIZE, &out_len, 320, 240);
|
||||||
|
}
|
||||||
|
if (ctx->stop_requested) { ctx->thumb_task=NULL; ctx->thumb_in_progress=false; vTaskDelete(NULL); return; }
|
||||||
|
if (r>0) {
|
||||||
|
ctx->thumb_len=out_len;
|
||||||
|
char saved_tmp[320]={0};
|
||||||
|
const char* saved = save_buffer_to_cache(ctx, p->id, ctx->thumb_buf, out_len, saved_tmp, sizeof(saved_tmp));
|
||||||
|
if (saved) { strncpy(ctx->saved_img_path, saved_tmp, sizeof(ctx->saved_img_path)-1); ctx->has_image=true; snprintf(ctx->status_text, sizeof(ctx->status_text), "PNG %d bytes %dx%d", (int)out_len, ctx->img_w, ctx->img_h); }
|
||||||
|
else { ctx->has_image=false; snprintf(ctx->status_text, sizeof(ctx->status_text), "PNG %d save fail", (int)out_len); }
|
||||||
|
} else {
|
||||||
|
ctx->thumb_len=0; ctx->has_image=false; snprintf(ctx->status_text, sizeof(ctx->status_text), "Proxy PNG fail %d", r);
|
||||||
|
}
|
||||||
|
ui_update_all(ctx); show_chrome(ctx);
|
||||||
|
ctx->thumb_task=NULL; ctx->thumb_in_progress=false; vTaskDelete(NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void start_thumb_task(AppCtx* ctx) {
|
||||||
|
if (!ctx || ctx->thumb_in_progress || ctx->stop_requested || photo_count==0) return;
|
||||||
|
ctx->thumb_in_progress=true; ctx->thumb_len=0; ctx->has_image=false;
|
||||||
|
if (xTaskCreate(thumb_task_fn, "thumb_fetch", 8192, ctx, 5, &ctx->thumb_task)!=pdPASS) { ctx->thumb_in_progress=false; ctx->thumb_task=NULL; }
|
||||||
|
}
|
||||||
|
|
||||||
|
static void fetch_task_fn(void* arg) {
|
||||||
|
AppCtx* ctx = (AppCtx*)arg;
|
||||||
|
if (!ctx) { vTaskDelete(NULL); return; }
|
||||||
|
if (ctx->stop_requested) { ctx->fetch_task=NULL; ctx->fetch_in_progress=false; vTaskDelete(NULL); return; }
|
||||||
|
ctx->fetch_in_progress=true;
|
||||||
|
const char* fetch_name = ctx->current_person_name[0] ? ctx->current_person_name : PERSON_NAME;
|
||||||
|
const char* fetch_id = ctx->current_person_id[0] ? ctx->current_person_id : PERSONS[0].id;
|
||||||
|
snprintf(ctx->status_text, sizeof(ctx->status_text), "Fetching %s photos...", fetch_name);
|
||||||
|
ui_update_all(ctx);
|
||||||
|
int n = fetch_person_assets(fetch_id);
|
||||||
|
if (ctx->stop_requested) { ctx->fetch_task=NULL; ctx->fetch_in_progress=false; vTaskDelete(NULL); return; }
|
||||||
|
if (n>0) {
|
||||||
|
ctx->current_idx=0;
|
||||||
|
snprintf(ctx->status_text, sizeof(ctx->status_text), "Loaded %d photos (random %dx%d)", n, ctx->img_w, ctx->img_h);
|
||||||
|
ui_update_all(ctx);
|
||||||
|
if (!ctx->thumb_buf) {
|
||||||
|
ctx->thumb_buf = heap_caps_malloc(THUMB_BUF_SIZE, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||||
|
if (!ctx->thumb_buf) ctx->thumb_buf = heap_caps_malloc(THUMB_BUF_SIZE, MALLOC_CAP_DEFAULT | MALLOC_CAP_8BIT);
|
||||||
|
}
|
||||||
|
if (ctx->thumb_buf && !ctx->stop_requested) {
|
||||||
|
Photo* p=&photos[0];
|
||||||
|
char cache_path[320]={0}; size_t cached_size=0;
|
||||||
|
if (load_from_cache(ctx, p->id, cache_path, sizeof(cache_path), &cached_size)) {
|
||||||
|
strncpy(ctx->saved_img_path, cache_path, sizeof(ctx->saved_img_path)-1);
|
||||||
|
ctx->has_image=true; ctx->thumb_len=cached_size;
|
||||||
|
snprintf(ctx->status_text, sizeof(ctx->status_text), "Cached PNG %d random %dx%d", (int)cached_size, ctx->img_w, ctx->img_h);
|
||||||
|
ui_update_all(ctx); show_chrome(ctx);
|
||||||
|
} else {
|
||||||
|
size_t ol=0;
|
||||||
|
int r=fetch_png_from_proxy(p->id, ctx->thumb_buf, THUMB_BUF_SIZE, &ol, ctx->img_w, ctx->img_h);
|
||||||
|
if (r<=0 && (ctx->img_w != 320 || ctx->img_h != 240)) {
|
||||||
|
ESP_LOGW(TAG, "First image proxy fail %dx%d, trying 320x240", ctx->img_w, ctx->img_h);
|
||||||
|
r=fetch_png_from_proxy(p->id, ctx->thumb_buf, THUMB_BUF_SIZE, &ol, 320, 240);
|
||||||
|
}
|
||||||
|
if (ctx->stop_requested) { ctx->fetch_task=NULL; ctx->fetch_in_progress=false; vTaskDelete(NULL); return; }
|
||||||
|
if (r>0) {
|
||||||
|
ctx->thumb_len=ol;
|
||||||
|
char saved_tmp[320]={0};
|
||||||
|
const char* saved=save_buffer_to_cache(ctx, p->id, ctx->thumb_buf, ol, saved_tmp, sizeof(saved_tmp));
|
||||||
|
if (saved) { strncpy(ctx->saved_img_path, saved_tmp, sizeof(ctx->saved_img_path)-1); ctx->has_image=true; snprintf(ctx->status_text, sizeof(ctx->status_text), "PNG %d random %dx%d", (int)ol, ctx->img_w, ctx->img_h); }
|
||||||
|
} else {
|
||||||
|
snprintf(ctx->status_text, sizeof(ctx->status_text), "First PNG fail %d", r);
|
||||||
|
}
|
||||||
|
ui_update_all(ctx); show_chrome(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
int cached = scan_cache_dir_for_photos(ctx);
|
||||||
|
if (cached>0) {
|
||||||
|
ctx->current_idx=0;
|
||||||
|
snprintf(ctx->status_text, sizeof(ctx->status_text), "Offline: %d cached", cached);
|
||||||
|
ui_update_all(ctx);
|
||||||
|
Photo* p=&photos[0];
|
||||||
|
char cp[320]={0}; size_t sz=0;
|
||||||
|
if (load_from_cache(ctx, p->id, cp, sizeof(cp), &sz)) { strncpy(ctx->saved_img_path, cp, sizeof(ctx->saved_img_path)-1); ctx->has_image=true; ctx->thumb_len=sz; snprintf(ctx->status_text, sizeof(ctx->status_text), "Offline cached %d %dx%d", (int)sz, ctx->img_w, ctx->img_h); ui_update_all(ctx); show_chrome(ctx); }
|
||||||
|
} else {
|
||||||
|
snprintf(ctx->status_text, sizeof(ctx->status_text), "Fetch failed %s - no cache", IMMICH_BASE_URL);
|
||||||
|
ui_update_all(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx->fetch_task=NULL; ctx->fetch_in_progress=false; vTaskDelete(NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void start_fetch_task(AppCtx* ctx) {
|
||||||
|
if (!ctx || ctx->fetch_in_progress || ctx->stop_requested) return;
|
||||||
|
ctx->fetch_in_progress=true;
|
||||||
|
if (xTaskCreate(fetch_task_fn, "immich_fetch", 12288, ctx, 5, &ctx->fetch_task)!=pdPASS) { ctx->fetch_in_progress=false; ctx->fetch_task=NULL; }
|
||||||
|
}
|
||||||
|
|
||||||
|
static void on_image_click(lv_event_t* e) {
|
||||||
|
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
|
||||||
|
if (!ctx) return;
|
||||||
|
bool is_hidden = false;
|
||||||
|
if (tt_lvgl_lock(pdMS_TO_TICKS(200))) {
|
||||||
|
if (ctx->ctrl_bar) is_hidden = lv_obj_has_flag(ctx->ctrl_bar, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
else if (ctx->header_bar) is_hidden = lv_obj_has_flag(ctx->header_bar, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
}
|
||||||
|
if (is_hidden) show_chrome(ctx); else hide_chrome(ctx);
|
||||||
|
}
|
||||||
|
static void prev_event_cb(lv_event_t* e) {
|
||||||
|
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
|
||||||
|
if (!ctx || photo_count==0 || ctx->thumb_in_progress || ctx->fetch_in_progress || ctx->stop_requested) return;
|
||||||
|
ctx->current_idx--; if (ctx->current_idx<0) ctx->current_idx=photo_count-1;
|
||||||
|
ctx->thumb_len=0; ctx->has_image=false;
|
||||||
|
snprintf(ctx->status_text, sizeof(ctx->status_text), "Loading %d/%d %dx%d", ctx->current_idx+1, photo_count, ctx->img_w, ctx->img_h);
|
||||||
|
ui_update_all(ctx); show_chrome(ctx); start_thumb_task(ctx);
|
||||||
|
}
|
||||||
|
static void next_event_cb(lv_event_t* e) {
|
||||||
|
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
|
||||||
|
if (!ctx || photo_count==0 || ctx->thumb_in_progress || ctx->fetch_in_progress || ctx->stop_requested) return;
|
||||||
|
ctx->current_idx++; if (ctx->current_idx>=photo_count) ctx->current_idx=0;
|
||||||
|
ctx->thumb_len=0; ctx->has_image=false;
|
||||||
|
snprintf(ctx->status_text, sizeof(ctx->status_text), "Loading %d/%d %dx%d", ctx->current_idx+1, photo_count, ctx->img_w, ctx->img_h);
|
||||||
|
ui_update_all(ctx); show_chrome(ctx); start_thumb_task(ctx);
|
||||||
|
}
|
||||||
|
static void reload_event_cb(lv_event_t* e) {
|
||||||
|
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
|
||||||
|
if (!ctx || ctx->fetch_in_progress || ctx->thumb_in_progress || ctx->stop_requested) return;
|
||||||
|
photo_count=0; ctx->current_idx=0; ctx->thumb_len=0; ctx->has_image=false;
|
||||||
|
snprintf(ctx->status_text, sizeof(ctx->status_text), "Reloading %dx%d...", ctx->img_w, ctx->img_h);
|
||||||
|
ui_update_all(ctx); show_chrome(ctx); start_fetch_task(ctx);
|
||||||
|
}
|
||||||
|
static void show_picker(AppCtx* ctx);
|
||||||
|
static void show_player(AppCtx* ctx);
|
||||||
|
|
||||||
|
static void back_event_cb(lv_event_t* e) {
|
||||||
|
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
|
||||||
|
if (!ctx) { tt_app_stop(); return; }
|
||||||
|
// If in player, go back to person picker like Bible book selector
|
||||||
|
if (ctx->picker_wrapper && ctx->player_wrapper) {
|
||||||
|
if (!tt_lvgl_lock(pdMS_TO_TICKS(500))) { tt_app_stop(); return; }
|
||||||
|
bool player_visible = !lv_obj_has_flag(ctx->player_wrapper, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
if (player_visible) {
|
||||||
|
show_picker(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tt_app_stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void* create_data(void) {
|
||||||
|
AppCtx* ctx = calloc(1, sizeof(AppCtx));
|
||||||
|
if (ctx) {
|
||||||
|
ctx->current_idx=0; ctx->disp_w=FALLBACK_W; ctx->disp_h=FALLBACK_H; ctx->img_w=FALLBACK_W; ctx->img_h=FALLBACK_H;
|
||||||
|
strncpy(ctx->current_person_id, PERSONS[0].id, sizeof(ctx->current_person_id)-1);
|
||||||
|
strncpy(ctx->current_person_name, PERSONS[0].name, sizeof(ctx->current_person_name)-1);
|
||||||
|
snprintf(ctx->status_text, sizeof(ctx->status_text), "Init %s proxy %s (random) person %s", IMMICH_BASE_URL, RESIZE_PROXY_URL, ctx->current_person_name);
|
||||||
|
ctx->thumb_buf = heap_caps_malloc(THUMB_BUF_SIZE, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||||
|
if (!ctx->thumb_buf) ctx->thumb_buf = heap_caps_malloc(THUMB_BUF_SIZE, MALLOC_CAP_DEFAULT | MALLOC_CAP_8BIT);
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
static void destroy_data(void* data) {
|
||||||
|
AppCtx* ctx = (AppCtx*)data; if (!ctx) return;
|
||||||
|
if (ctx->fetch_task || ctx->thumb_task) {
|
||||||
|
int waits=100;
|
||||||
|
while ((ctx->fetch_task || ctx->thumb_task) && waits-- >0) vTaskDelay(pdMS_TO_TICKS(50));
|
||||||
|
}
|
||||||
|
if (ctx->fetch_task || ctx->thumb_task) return; // leak to avoid crash
|
||||||
|
if (ctx->thumb_buf) { heap_caps_free(ctx->thumb_buf); ctx->thumb_buf=NULL; }
|
||||||
|
free(ctx);
|
||||||
|
}
|
||||||
|
static void on_create(AppHandle app, void* data) { AppCtx* ctx=(AppCtx*)data; if(ctx) ctx->app=app; ESP_LOGI(TAG,"onCreate proxy=%s random %dx%d person %s", RESIZE_PROXY_URL, ctx?ctx->img_w:0, ctx?ctx->img_h:0, ctx?ctx->current_person_name:"?"); }
|
||||||
|
static void on_destroy(AppHandle app, void* data) { (void)app;(void)data; }
|
||||||
|
|
||||||
|
static void person_selected_cb(lv_event_t* e) {
|
||||||
|
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
|
||||||
|
if (!ctx) return;
|
||||||
|
int idx = (int)(intptr_t)lv_obj_get_user_data(lv_event_get_target(e));
|
||||||
|
if (idx < 0 || idx >= PERSONS_COUNT) return;
|
||||||
|
const PersonInfo* p = &PERSONS[idx];
|
||||||
|
strncpy(ctx->current_person_id, p->id, sizeof(ctx->current_person_id)-1);
|
||||||
|
strncpy(ctx->current_person_name, p->name, sizeof(ctx->current_person_name)-1);
|
||||||
|
ESP_LOGI(TAG, "Person selected %s (%s)", p->name, p->id);
|
||||||
|
photo_count = 0;
|
||||||
|
ctx->current_idx = 0;
|
||||||
|
ctx->thumb_len = 0;
|
||||||
|
ctx->has_image = false;
|
||||||
|
show_player(ctx);
|
||||||
|
start_fetch_task(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void build_person_picker(AppCtx* ctx, lv_obj_t* parent) {
|
||||||
|
ctx->picker_wrapper = lv_obj_create(parent);
|
||||||
|
lv_obj_set_size(ctx->picker_wrapper, LV_PCT(100), LV_PCT(100));
|
||||||
|
lv_obj_set_style_border_width(ctx->picker_wrapper, 0, 0);
|
||||||
|
lv_obj_set_style_pad_all(ctx->picker_wrapper, 0, 0);
|
||||||
|
lv_obj_set_style_bg_color(ctx->picker_wrapper, lv_color_hex(0x121212), LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_bg_opa(ctx->picker_wrapper, LV_OPA_COVER, LV_PART_MAIN);
|
||||||
|
|
||||||
|
lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(ctx->picker_wrapper, ctx->app);
|
||||||
|
|
||||||
|
ctx->lst_persons = lv_list_create(ctx->picker_wrapper);
|
||||||
|
lv_obj_set_width(ctx->lst_persons, LV_PCT(100));
|
||||||
|
lv_obj_align_to(ctx->lst_persons, toolbar, LV_ALIGN_OUT_BOTTOM_MID, 0, 0);
|
||||||
|
// Calculate height
|
||||||
|
int32_t th = 44;
|
||||||
|
// Use parent height minus toolbar
|
||||||
|
lv_obj_set_height(ctx->lst_persons, LV_PCT(90));
|
||||||
|
lv_obj_set_style_bg_color(ctx->lst_persons, lv_color_hex(0x121212), LV_PART_MAIN);
|
||||||
|
|
||||||
|
for (int i=0;i<PERSONS_COUNT;i++) {
|
||||||
|
const PersonInfo* person = &PERSONS[i];
|
||||||
|
char label[64];
|
||||||
|
if (person->count_str[0]) snprintf(label, sizeof(label), "%s (%s)", person->name, person->count_str);
|
||||||
|
else snprintf(label, sizeof(label), "%s", person->name);
|
||||||
|
lv_obj_t* btn = lv_list_add_button(ctx->lst_persons, LV_SYMBOL_DIRECTORY, label);
|
||||||
|
lv_obj_set_user_data(btn, (void*)(intptr_t)i);
|
||||||
|
lv_obj_add_event_cb(btn, person_selected_cb, LV_EVENT_CLICKED, ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void show_picker(AppCtx* ctx) {
|
||||||
|
if (!ctx) return;
|
||||||
|
if (!tt_lvgl_lock(pdMS_TO_TICKS(500))) return;
|
||||||
|
if (ctx->player_wrapper) lv_obj_add_flag(ctx->player_wrapper, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
if (ctx->picker_wrapper) lv_obj_remove_flag(ctx->picker_wrapper, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void show_player(AppCtx* ctx) {
|
||||||
|
if (!ctx) return;
|
||||||
|
if (!tt_lvgl_lock(pdMS_TO_TICKS(500))) return;
|
||||||
|
if (ctx->picker_wrapper) lv_obj_add_flag(ctx->picker_wrapper, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
if (ctx->player_wrapper) lv_obj_remove_flag(ctx->player_wrapper, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
show_chrome(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void on_show(AppHandle app, void* data, lv_obj_t* parent) {
|
||||||
|
AppCtx* ctx=(AppCtx*)data; if(!ctx) return;
|
||||||
|
ctx->visible=true; ctx->stop_requested=false; ctx->app=app;
|
||||||
|
detect_display_size(ctx, parent);
|
||||||
|
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
lv_obj_set_style_border_width(parent, 0, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_pad_all(parent, 0, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_pad_gap(parent, 0, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_bg_color(parent, lv_color_hex(0x121212), LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_bg_opa(parent, LV_OPA_COVER, LV_PART_MAIN);
|
||||||
|
|
||||||
|
// Picker for persons (like Bible book selector)
|
||||||
|
build_person_picker(ctx, parent);
|
||||||
|
|
||||||
|
// Fullscreen player like Bible app - image background, only bottom controls
|
||||||
|
ctx->player_wrapper = lv_obj_create(parent);
|
||||||
|
lv_obj_set_size(ctx->player_wrapper, LV_PCT(100), LV_PCT(100));
|
||||||
|
lv_obj_set_style_border_width(ctx->player_wrapper, 0, 0);
|
||||||
|
lv_obj_set_style_pad_all(ctx->player_wrapper, 0, 0);
|
||||||
|
lv_obj_set_style_pad_gap(ctx->player_wrapper, 0, 0);
|
||||||
|
lv_obj_set_style_bg_color(ctx->player_wrapper, lv_color_hex(0x121212), LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_bg_opa(ctx->player_wrapper, LV_OPA_COVER, LV_PART_MAIN);
|
||||||
|
lv_obj_remove_flag(ctx->player_wrapper, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
lv_obj_add_flag(ctx->player_wrapper, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
|
||||||
|
// Image covers entire background
|
||||||
|
ctx->img_page = lv_image_create(ctx->player_wrapper);
|
||||||
|
lv_obj_set_size(ctx->img_page, LV_PCT(100), LV_PCT(100));
|
||||||
|
lv_obj_align(ctx->img_page, LV_ALIGN_CENTER, 0, 0);
|
||||||
|
lv_obj_set_style_bg_opa(ctx->img_page, LV_OPA_TRANSP, 0);
|
||||||
|
lv_obj_set_style_pad_all(ctx->img_page, 0, 0);
|
||||||
|
lv_obj_set_style_border_width(ctx->img_page, 0, 0);
|
||||||
|
lv_obj_remove_flag(ctx->img_page, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
lv_obj_add_flag(ctx->img_page, LV_OBJ_FLAG_CLICKABLE);
|
||||||
|
lv_obj_add_event_cb(ctx->img_page, on_image_click, LV_EVENT_CLICKED, ctx);
|
||||||
|
|
||||||
|
ctx->header_bar = NULL;
|
||||||
|
ctx->lbl_book_title = NULL;
|
||||||
|
ctx->lbl_indicator = NULL;
|
||||||
|
ctx->lbl_info = NULL;
|
||||||
|
ctx->lbl_status = NULL;
|
||||||
|
ctx->btn_back = NULL;
|
||||||
|
|
||||||
|
ctx->ctrl_bar = lv_obj_create(ctx->player_wrapper);
|
||||||
|
lv_obj_set_size(ctx->ctrl_bar, LV_PCT(100), 44);
|
||||||
|
lv_obj_align(ctx->ctrl_bar, LV_ALIGN_BOTTOM_MID, 0, 0);
|
||||||
|
lv_obj_set_style_radius(ctx->ctrl_bar, 0, 0);
|
||||||
|
lv_obj_set_style_bg_color(ctx->ctrl_bar, lv_color_hex(0x0E0E14), LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_bg_opa(ctx->ctrl_bar, LV_OPA_80, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_border_width(ctx->ctrl_bar, 0, 0);
|
||||||
|
lv_obj_set_style_pad_all(ctx->ctrl_bar, 6, 0);
|
||||||
|
lv_obj_set_style_pad_gap(ctx->ctrl_bar, 8, 0);
|
||||||
|
lv_obj_set_flex_flow(ctx->ctrl_bar, LV_FLEX_FLOW_ROW);
|
||||||
|
lv_obj_set_flex_align(ctx->ctrl_bar, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||||
|
lv_obj_remove_flag(ctx->ctrl_bar, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
|
ctx->btn_back = lv_btn_create(ctx->ctrl_bar);
|
||||||
|
lv_obj_set_size(ctx->btn_back, 44, 32);
|
||||||
|
lv_obj_set_style_radius(ctx->btn_back, 8, 0);
|
||||||
|
lv_obj_set_style_bg_color(ctx->btn_back, lv_color_hex(0x252538), LV_PART_MAIN);
|
||||||
|
lv_obj_t* lbl_back = lv_label_create(ctx->btn_back); lv_label_set_text(lbl_back, LV_SYMBOL_LEFT); lv_obj_center(lbl_back);
|
||||||
|
lv_obj_add_event_cb(ctx->btn_back, back_event_cb, LV_EVENT_CLICKED, ctx);
|
||||||
|
|
||||||
|
ctx->btn_prev = lv_btn_create(ctx->ctrl_bar);
|
||||||
|
lv_obj_set_size(ctx->btn_prev, 54, 32);
|
||||||
|
lv_obj_set_style_radius(ctx->btn_prev, 16, 0);
|
||||||
|
lv_obj_set_style_bg_color(ctx->btn_prev, lv_color_hex(0x313244), LV_PART_MAIN);
|
||||||
|
lv_obj_t* lp = lv_label_create(ctx->btn_prev); lv_label_set_text(lp, LV_SYMBOL_PREV); lv_obj_center(lp);
|
||||||
|
lv_obj_add_event_cb(ctx->btn_prev, prev_event_cb, LV_EVENT_CLICKED, ctx);
|
||||||
|
|
||||||
|
ctx->btn_reload = lv_btn_create(ctx->ctrl_bar);
|
||||||
|
lv_obj_set_size(ctx->btn_reload, 44, 32);
|
||||||
|
lv_obj_set_style_radius(ctx->btn_reload, 16, 0);
|
||||||
|
lv_obj_set_style_bg_color(ctx->btn_reload, lv_color_hex(0x313244), LV_PART_MAIN);
|
||||||
|
lv_obj_t* lr = lv_label_create(ctx->btn_reload); lv_label_set_text(lr, LV_SYMBOL_REFRESH); lv_obj_center(lr);
|
||||||
|
lv_obj_add_event_cb(ctx->btn_reload, reload_event_cb, LV_EVENT_CLICKED, ctx);
|
||||||
|
|
||||||
|
ctx->btn_next = lv_btn_create(ctx->ctrl_bar);
|
||||||
|
lv_obj_set_size(ctx->btn_next, 54, 32);
|
||||||
|
lv_obj_set_style_radius(ctx->btn_next, 16, 0);
|
||||||
|
lv_obj_set_style_bg_color(ctx->btn_next, lv_color_hex(0x89B4FA), LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_text_color(ctx->btn_next, lv_color_hex(0x11111B), LV_PART_MAIN);
|
||||||
|
lv_obj_t* ln = lv_label_create(ctx->btn_next); lv_label_set_text(ln, LV_SYMBOL_NEXT); lv_obj_center(ln);
|
||||||
|
lv_obj_add_event_cb(ctx->btn_next, next_event_cb, LV_EVENT_CLICKED, ctx);
|
||||||
|
|
||||||
|
if (tt_lvgl_lock(pdMS_TO_TICKS(500))) {
|
||||||
|
if (ctx->slideshow_timer) { lv_timer_delete(ctx->slideshow_timer); ctx->slideshow_timer=NULL; }
|
||||||
|
ctx->slideshow_timer = lv_timer_create(slideshow_timer_cb, 60000, ctx);
|
||||||
|
if (ctx->autohide_timer) { lv_timer_delete(ctx->autohide_timer); ctx->autohide_timer=NULL; }
|
||||||
|
ctx->autohide_timer = NULL;
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always show person picker first like Bible book selector
|
||||||
|
show_picker(ctx);
|
||||||
|
// If we already have photos for current person, user can still see them after selecting
|
||||||
|
// Don't auto-start fetch here; wait for person selection to fetch fresh random page
|
||||||
|
// But if we have cached photos from previous session for current person, we could show player directly
|
||||||
|
// For now, just show picker and let user choose person
|
||||||
|
if (photo_count>0 && ctx->current_person_id[0]!='\0') {
|
||||||
|
// Optionally prefetch in background
|
||||||
|
snprintf(ctx->status_text,sizeof(ctx->status_text),"Cached %d for %s - select person", photo_count, ctx->current_person_name);
|
||||||
|
} else {
|
||||||
|
snprintf(ctx->status_text,sizeof(ctx->status_text),"Select a person");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void on_hide(AppHandle app, void* data) {
|
||||||
|
(void)app; AppCtx* ctx=(AppCtx*)data; if(!ctx) return;
|
||||||
|
ESP_LOGI(TAG, "onHide start, stopping tasks");
|
||||||
|
ctx->stop_requested=true; ctx->visible=false;
|
||||||
|
if (tt_lvgl_lock(pdMS_TO_TICKS(500))) {
|
||||||
|
if (ctx->slideshow_timer) { lv_timer_delete(ctx->slideshow_timer); ctx->slideshow_timer=NULL; }
|
||||||
|
if (ctx->autohide_timer) { lv_timer_delete(ctx->autohide_timer); ctx->autohide_timer=NULL; }
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
}
|
||||||
|
int timeout=200;
|
||||||
|
while((ctx->fetch_task || ctx->thumb_task) && timeout>0){ vTaskDelay(pdMS_TO_TICKS(50)); timeout--; }
|
||||||
|
if (ctx->fetch_task || ctx->thumb_task) ESP_LOGW(TAG, "onHide timeout fetch=%p thumb=%p", ctx->fetch_task, ctx->thumb_task);
|
||||||
|
else ESP_LOGI(TAG, "onHide tasks stopped cleanly");
|
||||||
|
}
|
||||||
|
|
||||||
|
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,7 @@
|
|||||||
|
manifest.version=0.2
|
||||||
|
target.sdk=0.8.0-dev
|
||||||
|
target.platforms=esp32s3,esp32p4
|
||||||
|
app.id=one.tactility.imichelias
|
||||||
|
app.version.name=0.1.0
|
||||||
|
app.version.code=1
|
||||||
|
app.name=Immich Elias
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32s3,esp32p4
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.m5unittest
|
||||||
platforms=esp32s3,esp32p4
|
app.version.name=0.4.0
|
||||||
[app]
|
app.version.code=4
|
||||||
id=one.tactility.m5unittest
|
app.name=M5 Unit Test
|
||||||
versionName=0.1.0
|
|
||||||
versionCode=1
|
|
||||||
name=M5 Unit Test
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.magic8ball
|
||||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
app.version.name=0.5.0
|
||||||
[app]
|
app.version.code=5
|
||||||
id=one.tactility.magic8ball
|
app.name=Magic 8-Ball
|
||||||
versionName=0.2.0
|
|
||||||
versionCode=2
|
|
||||||
name=Magic 8-Ball
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32s3
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.mcpscreen
|
||||||
platforms=esp32s3
|
app.version.name=0.1.0
|
||||||
[app]
|
app.version.code=1
|
||||||
id=one.tactility.mcpscreen
|
app.name=MCP Screen
|
||||||
versionName=0.1.0
|
|
||||||
versionCode=1
|
|
||||||
name=MCP Screen
|
|
||||||
|
|||||||
@@ -211,12 +211,29 @@ void MediaKeys::startHid() {
|
|||||||
if (tt_lvgl_hardware_keyboard_is_available()) enterKeyMode();
|
if (tt_lvgl_hardware_keyboard_is_available()) enterKeyMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void MediaKeys::teardownBt() {
|
||||||
|
// Remove callback FIRST - stops any in-flight BT events from firing against
|
||||||
|
// our (possibly already freed) UI widget pointers after this returns.
|
||||||
|
if (_btDevice) bluetooth_remove_event_callback(_btDevice, btEventCallback);
|
||||||
|
// Do NOT call bluetooth_hid_device_stop here: it calls ble_gatts_reset() /
|
||||||
|
// ble_gatts_start() which corrupts NimBLE heap while the host task is still
|
||||||
|
// running. HID device is a persistent kernel device; hid_device_start() cleans
|
||||||
|
// up stale context on next use. Explicit stop is handled by handleSwitchToggle.
|
||||||
|
// Restore the radio/device to the state we found them in.
|
||||||
|
if (_btDevice && _radioWasOff) bluetooth_set_radio_enabled(_btDevice, false);
|
||||||
|
if (_btDevice && _deviceWasStarted) device_stop(_btDevice);
|
||||||
|
_btDevice = nullptr;
|
||||||
|
_hidDevice = nullptr;
|
||||||
|
_radioWasOff = false;
|
||||||
|
_deviceWasStarted = false;
|
||||||
|
}
|
||||||
|
|
||||||
void MediaKeys::handleSwitchToggle(bool enabled) {
|
void MediaKeys::handleSwitchToggle(bool enabled) {
|
||||||
LOG_I(TAG, "Switch: %s", enabled ? "ON" : "OFF");
|
LOG_I(TAG, "Switch: %s", enabled ? "ON" : "OFF");
|
||||||
_isEnabled = enabled;
|
_isEnabled = enabled;
|
||||||
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
_btDevice = bluetooth_find_first_ready_device();
|
_btDevice = device_find_first_by_type(&BLUETOOTH_TYPE);
|
||||||
if (!_btDevice) {
|
if (!_btDevice) {
|
||||||
LOG_E(TAG, "No Bluetooth device found");
|
LOG_E(TAG, "No Bluetooth device found");
|
||||||
_isEnabled = false;
|
_isEnabled = false;
|
||||||
@@ -224,6 +241,19 @@ void MediaKeys::handleSwitchToggle(bool enabled) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Device may not be started yet (BT disabled in DTS by default to save memory).
|
||||||
|
if (!device_is_ready(_btDevice)) {
|
||||||
|
LOG_I(TAG, "BT device not started, starting now");
|
||||||
|
if (device_start(_btDevice) != ERROR_NONE) {
|
||||||
|
LOG_E(TAG, "Failed to start BT device");
|
||||||
|
_btDevice = nullptr;
|
||||||
|
_isEnabled = false;
|
||||||
|
if (_switchWidget) lv_obj_remove_state(_switchWidget, LV_STATE_CHECKED);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_deviceWasStarted = true;
|
||||||
|
}
|
||||||
|
|
||||||
bluetooth_set_device_name(_btDevice, "Tactility Media Keys");
|
bluetooth_set_device_name(_btDevice, "Tactility Media Keys");
|
||||||
|
|
||||||
// Register callback before enabling radio so we don't miss the state-change event.
|
// Register callback before enabling radio so we don't miss the state-change event.
|
||||||
@@ -247,12 +277,10 @@ void MediaKeys::handleSwitchToggle(bool enabled) {
|
|||||||
} else {
|
} else {
|
||||||
_radioEnabling = false;
|
_radioEnabling = false;
|
||||||
if (tt_lvgl_hardware_keyboard_is_available()) exitKeyMode();
|
if (tt_lvgl_hardware_keyboard_is_available()) exitKeyMode();
|
||||||
|
// Explicit user toggle-off: stop HID cleanly (safe here since we're on the
|
||||||
|
// LVGL task and the user intentionally disabled, so no race with app teardown).
|
||||||
if (_hidDevice) bluetooth_hid_device_stop(_hidDevice);
|
if (_hidDevice) bluetooth_hid_device_stop(_hidDevice);
|
||||||
if (_btDevice) bluetooth_remove_event_callback(_btDevice, btEventCallback);
|
teardownBt();
|
||||||
if (_btDevice && _radioWasOff) bluetooth_set_radio_enabled(_btDevice, false);
|
|
||||||
_radioWasOff = false;
|
|
||||||
_btDevice = nullptr;
|
|
||||||
_hidDevice = nullptr;
|
|
||||||
if (_mainWrapper) lv_obj_add_flag(_mainWrapper, LV_OBJ_FLAG_HIDDEN);
|
if (_mainWrapper) lv_obj_add_flag(_mainWrapper, LV_OBJ_FLAG_HIDDEN);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -321,19 +349,23 @@ void MediaKeys::onShow(AppHandle appHandle, lv_obj_t* parent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lv_obj_add_flag(_mainWrapper, LV_OBJ_FLAG_HIDDEN);
|
lv_obj_add_flag(_mainWrapper, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
|
||||||
|
// Auto-enable if BT is already on (turned on via QuickPanel/Settings before opening app).
|
||||||
|
struct Device* btDev = device_find_first_by_type(&BLUETOOTH_TYPE);
|
||||||
|
if (btDev && device_is_ready(btDev)) {
|
||||||
|
enum BtRadioState radioState;
|
||||||
|
if (bluetooth_get_radio_state(btDev, &radioState) == ERROR_NONE && radioState == BT_RADIO_STATE_ON) {
|
||||||
|
lv_obj_add_state(_switchWidget, LV_STATE_CHECKED);
|
||||||
|
handleSwitchToggle(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void MediaKeys::onHide(AppHandle /*appHandle*/) {
|
void MediaKeys::onHide(AppHandle /*appHandle*/) {
|
||||||
if (_hidDevice) bluetooth_hid_device_stop(_hidDevice);
|
|
||||||
if (_btDevice) bluetooth_remove_event_callback(_btDevice, btEventCallback);
|
|
||||||
if (_btDevice && _radioWasOff) bluetooth_set_radio_enabled(_btDevice, false);
|
|
||||||
_btDevice = nullptr;
|
|
||||||
_hidDevice = nullptr;
|
|
||||||
_isEnabled = false;
|
|
||||||
_radioEnabling = false;
|
_radioEnabling = false;
|
||||||
_radioWasOff = false;
|
_isEnabled = false;
|
||||||
|
|
||||||
if (tt_lvgl_hardware_keyboard_is_available()) exitKeyMode();
|
if (tt_lvgl_hardware_keyboard_is_available()) exitKeyMode();
|
||||||
|
teardownBt();
|
||||||
if (_keyHighlightTimer) {
|
if (_keyHighlightTimer) {
|
||||||
lv_timer_delete(_keyHighlightTimer);
|
lv_timer_delete(_keyHighlightTimer);
|
||||||
_keyHighlightTimer = nullptr;
|
_keyHighlightTimer = nullptr;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include <TactilityCpp/App.h>
|
#include <TactilityCpp/App.h>
|
||||||
#include <lvgl.h>
|
#include <lvgl.h>
|
||||||
|
#include <tactility/device.h>
|
||||||
#include <tactility/drivers/bluetooth.h>
|
#include <tactility/drivers/bluetooth.h>
|
||||||
#include <tactility/drivers/bluetooth_hid_device.h>
|
#include <tactility/drivers/bluetooth_hid_device.h>
|
||||||
#include <tt_app.h>
|
#include <tt_app.h>
|
||||||
@@ -23,9 +24,10 @@ class MediaKeys final : public App {
|
|||||||
struct Device* _hidDevice = nullptr;
|
struct Device* _hidDevice = nullptr;
|
||||||
|
|
||||||
// State - accessed from both LVGL thread and BT callback thread
|
// State - accessed from both LVGL thread and BT callback thread
|
||||||
std::atomic<bool> _isEnabled {false};
|
std::atomic<bool> _isEnabled {false};
|
||||||
std::atomic<bool> _radioEnabling{false}; // true while waiting for radio to come ON
|
std::atomic<bool> _radioEnabling {false}; // true while waiting for radio to come ON
|
||||||
std::atomic<bool> _radioWasOff {false}; // true if MediaKeys turned the radio on (so we turn it off)
|
std::atomic<bool> _radioWasOff {false}; // true if we turned the radio on (restore on exit)
|
||||||
|
std::atomic<bool> _deviceWasStarted{false}; // true if we called device_start (restore on exit)
|
||||||
|
|
||||||
// Static event callbacks
|
// Static event callbacks
|
||||||
static void onSwitchToggled(lv_event_t* e);
|
static void onSwitchToggled(lv_event_t* e);
|
||||||
@@ -36,6 +38,7 @@ class MediaKeys final : public App {
|
|||||||
static void sendKeyTask(void* param);
|
static void sendKeyTask(void* param);
|
||||||
|
|
||||||
// Instance methods called by static callbacks
|
// Instance methods called by static callbacks
|
||||||
|
void teardownBt(); // remove callback + stop HID + restore radio/device state
|
||||||
void handleSwitchToggle(bool enabled);
|
void handleSwitchToggle(bool enabled);
|
||||||
void handleButtonPress(uint32_t buttonId);
|
void handleButtonPress(uint32_t buttonId);
|
||||||
void startHid(); // called once radio is confirmed ON
|
void startHid(); // called once radio is confirmed ON
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32s3,esp32p4
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.mediakeys
|
||||||
platforms=esp32s3,esp32p4
|
app.version.name=0.4.0
|
||||||
[app]
|
app.version.code=4
|
||||||
id=one.tactility.mediakeys
|
app.name=Media Keys
|
||||||
versionName=0.1.0
|
app.description=Bluetooth media keys. Touch or Physical Keyboard control\nB - previous, P - play/pause, N - next, M - mute, D - volume down, U - volume up.\nQ or ESC to exit focus.
|
||||||
versionCode=1
|
|
||||||
name=Media Keys
|
|
||||||
description=Bluetooth media keys. Touch or Physical Keyboard control\nB - previous, P - play/pause, N - next, M - mute, D - volume down, U - volume up.\nQ or ESC to exit focus.
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
#include <tt_lvgl_toolbar.h>
|
#include <tt_lvgl_toolbar.h>
|
||||||
|
|
||||||
#include <tactility/device.h>
|
#include <tactility/device.h>
|
||||||
#include <tactility/drivers/i2s_controller.h>
|
#include <tactility/drivers/audio_stream.h>
|
||||||
|
#include <tactility/drivers/audio_codec.h>
|
||||||
|
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
@@ -27,7 +28,8 @@ typedef enum {
|
|||||||
} PlaybackState;
|
} PlaybackState;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
struct Device* i2s_dev;
|
struct Device* stream_dev;
|
||||||
|
AudioStreamHandle stream_handle;
|
||||||
char filepath[512];
|
char filepath[512];
|
||||||
PlaybackState state;
|
PlaybackState state;
|
||||||
|
|
||||||
@@ -119,17 +121,17 @@ static void mp3_playback_task(void* arg) {
|
|||||||
ctx->eof = false;
|
ctx->eof = false;
|
||||||
ctx->sample_rate = 0;
|
ctx->sample_rate = 0;
|
||||||
ctx->channels = 0;
|
ctx->channels = 0;
|
||||||
|
ctx->stream_handle = NULL;
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Starting MP3 playback: %s (size: %d bytes)", ctx->filepath, ctx->file_size);
|
ESP_LOGI(TAG, "Starting MP3 playback: %s (size: %d bytes)", ctx->filepath, ctx->file_size);
|
||||||
|
|
||||||
while (ctx->state != STATE_IDLE) {
|
while (ctx->state != STATE_IDLE) {
|
||||||
if (ctx->state == STATE_PAUSED) {
|
if (ctx->state == STATE_PAUSED) {
|
||||||
if (ctx->sample_rate != 0) {
|
if (ctx->stream_handle != NULL) {
|
||||||
// Reset I2S immediately on pause to stop DMA loops
|
// Close stream immediately on pause to stop DMA
|
||||||
device_lock(ctx->i2s_dev);
|
audio_stream_close(ctx->stream_handle);
|
||||||
i2s_controller_reset(ctx->i2s_dev);
|
ctx->stream_handle = NULL;
|
||||||
device_unlock(ctx->i2s_dev);
|
// Clear cached format to force re-open on resume
|
||||||
// Clear configuration cache to force reconfig on resume
|
|
||||||
ctx->sample_rate = 0;
|
ctx->sample_rate = 0;
|
||||||
ctx->channels = 0;
|
ctx->channels = 0;
|
||||||
}
|
}
|
||||||
@@ -172,30 +174,30 @@ static void mp3_playback_task(void* arg) {
|
|||||||
memmove(ctx->input_buf, ctx->input_buf + consumed, ctx->buffered_bytes);
|
memmove(ctx->input_buf, ctx->input_buf + consumed, ctx->buffered_bytes);
|
||||||
|
|
||||||
if (samples > 0) {
|
if (samples > 0) {
|
||||||
// Configure I2S if format changed
|
// Configure audio-stream if format changed
|
||||||
if (ctx->sample_rate != info.hz || ctx->channels != info.channels) {
|
if (ctx->sample_rate != info.hz || ctx->channels != info.channels) {
|
||||||
struct I2sConfig config = {
|
if (ctx->stream_handle != NULL) {
|
||||||
.communication_format = I2S_FORMAT_STAND_I2S,
|
audio_stream_close(ctx->stream_handle);
|
||||||
|
ctx->stream_handle = NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AudioStreamConfig config = {
|
||||||
.sample_rate = (uint32_t)info.hz,
|
.sample_rate = (uint32_t)info.hz,
|
||||||
.bits_per_sample = 16,
|
.bits_per_sample = 16,
|
||||||
.channel_left = 0,
|
.channels = (uint8_t)info.channels
|
||||||
.channel_right = (info.channels == 2) ? 1 : I2S_CHANNEL_NONE
|
|
||||||
};
|
};
|
||||||
|
|
||||||
device_lock(ctx->i2s_dev);
|
error_t err = audio_stream_open_output(ctx->stream_dev, &config, &ctx->stream_handle);
|
||||||
error_t err = i2s_controller_set_config(ctx->i2s_dev, &config);
|
|
||||||
device_unlock(ctx->i2s_dev);
|
|
||||||
|
|
||||||
if (err != ERROR_NONE) {
|
if (err != ERROR_NONE) {
|
||||||
ESP_LOGE(TAG, "Failed to set config: %d", err);
|
ESP_LOGE(TAG, "Failed to open audio stream: %d", err);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
ctx->sample_rate = info.hz;
|
ctx->sample_rate = info.hz;
|
||||||
ctx->channels = info.channels;
|
ctx->channels = info.channels;
|
||||||
ESP_LOGI(TAG, "I2S configured: %d Hz, %d channels", info.hz, info.channels);
|
ESP_LOGI(TAG, "Audio stream opened: %d Hz, %d channels", info.hz, info.channels);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adjust volume
|
// Adjust volume (after resampler)
|
||||||
int vol = ctx->volume;
|
int vol = ctx->volume;
|
||||||
int16_t* samples_ptr = (int16_t*)ctx->pcm_buf;
|
int16_t* samples_ptr = (int16_t*)ctx->pcm_buf;
|
||||||
size_t sample_count = (size_t)samples * info.channels;
|
size_t sample_count = (size_t)samples * info.channels;
|
||||||
@@ -204,15 +206,15 @@ static void mp3_playback_task(void* arg) {
|
|||||||
samples_ptr[i] = (int16_t)scaled;
|
samples_ptr[i] = (int16_t)scaled;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Play audio to I2S
|
// Play audio via audio-stream (resampled to native 44100)
|
||||||
size_t offset = 0;
|
size_t offset = 0;
|
||||||
size_t data_size = sample_count * sizeof(int16_t);
|
size_t data_size = sample_count * sizeof(int16_t);
|
||||||
bool write_err = false;
|
bool write_err = false;
|
||||||
while (offset < data_size && ctx->state == STATE_PLAYING) {
|
while (offset < data_size && ctx->state == STATE_PLAYING) {
|
||||||
size_t written = 0;
|
size_t written = 0;
|
||||||
error_t err = i2s_controller_write(ctx->i2s_dev, (uint8_t*)ctx->pcm_buf + offset, data_size - offset, &written, pdMS_TO_TICKS(250));
|
error_t err = audio_stream_write(ctx->stream_handle, (uint8_t*)ctx->pcm_buf + offset, data_size - offset, &written, pdMS_TO_TICKS(500));
|
||||||
if (err != ERROR_NONE || written == 0) {
|
if (err != ERROR_NONE || written == 0) {
|
||||||
ESP_LOGE(TAG, "I2S write failed: %d", err);
|
ESP_LOGE(TAG, "Audio stream write failed: %d", err);
|
||||||
write_err = true;
|
write_err = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -232,18 +234,15 @@ static void mp3_playback_task(void* arg) {
|
|||||||
lv_bar_set_value(ctx->bar_progress, pct, LV_ANIM_OFF);
|
lv_bar_set_value(ctx->bar_progress, pct, LV_ANIM_OFF);
|
||||||
tt_lvgl_unlock();
|
tt_lvgl_unlock();
|
||||||
}
|
}
|
||||||
|
|
||||||
// taskYIELD removed to avoid audio gaps - decoder loop is fast enough
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fclose(ctx->file);
|
fclose(ctx->file);
|
||||||
ctx->file = NULL;
|
ctx->file = NULL;
|
||||||
|
|
||||||
// Reset I2S controller to clean up DMA channels and stop looping noise
|
// Close audio stream to clean up DMA / resampler task
|
||||||
if (ctx->i2s_dev) {
|
if (ctx->stream_handle != NULL) {
|
||||||
device_lock(ctx->i2s_dev);
|
audio_stream_close(ctx->stream_handle);
|
||||||
i2s_controller_reset(ctx->i2s_dev);
|
ctx->stream_handle = NULL;
|
||||||
device_unlock(ctx->i2s_dev);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Playback task finished");
|
ESP_LOGI(TAG, "Playback task finished");
|
||||||
@@ -292,10 +291,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.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));
|
g_ctx.pcm_buf = (mp3d_sample_t*)malloc(MINIMP3_MAX_SAMPLES_PER_FRAME * sizeof(mp3d_sample_t));
|
||||||
|
|
||||||
// Find device
|
// Find audio-stream device (resampling layer over ES8311 codec)
|
||||||
g_ctx.i2s_dev = device_find_by_name("i2s0");
|
g_ctx.stream_dev = device_find_by_name("audio-stream");
|
||||||
if (!g_ctx.i2s_dev) {
|
if (!g_ctx.stream_dev) {
|
||||||
ESP_LOGE(TAG, "I2S device 'i2s0' not found!");
|
ESP_LOGE(TAG, "Audio-stream device not found!");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse launch parameters
|
// Parse launch parameters
|
||||||
@@ -393,8 +392,8 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
|||||||
lv_obj_center(lbl_stop);
|
lv_obj_center(lbl_stop);
|
||||||
lv_obj_add_event_cb(g_ctx.btn_stop, on_stop_click, LV_EVENT_CLICKED, &g_ctx);
|
lv_obj_add_event_cb(g_ctx.btn_stop, on_stop_click, LV_EVENT_CLICKED, &g_ctx);
|
||||||
|
|
||||||
if (!g_ctx.i2s_dev) {
|
if (!g_ctx.stream_dev) {
|
||||||
lv_label_set_text(g_ctx.lbl_status, "Error: I2S Not Found");
|
lv_label_set_text(g_ctx.lbl_status, "Error: Audio Not Found");
|
||||||
lv_obj_add_state(g_ctx.btn_play_pause, 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);
|
lv_obj_add_state(g_ctx.btn_stop, LV_STATE_DISABLED);
|
||||||
} else if (!g_ctx.input_buf || !g_ctx.pcm_buf) {
|
} else if (!g_ctx.input_buf || !g_ctx.pcm_buf) {
|
||||||
@@ -409,7 +408,7 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
|||||||
if (g_ctx.filepath[0] != '\0') {
|
if (g_ctx.filepath[0] != '\0') {
|
||||||
g_ctx.state = STATE_PLAYING;
|
g_ctx.state = STATE_PLAYING;
|
||||||
update_ui(&g_ctx);
|
update_ui(&g_ctx);
|
||||||
xTaskCreate(mp3_playback_task, "mp3_play", 4096, &g_ctx, 5, &g_ctx.playback_task_handle);
|
xTaskCreate(mp3_playback_task, "mp3_play", 6144, &g_ctx, 6, &g_ctx.playback_task_handle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -424,11 +423,10 @@ static void onHideApp(AppHandle app, void* data) {
|
|||||||
vTaskDelay(pdMS_TO_TICKS(10));
|
vTaskDelay(pdMS_TO_TICKS(10));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset I2S to ensure DMA channel is stopped
|
// Close any open audio stream
|
||||||
if (g_ctx.i2s_dev) {
|
if (g_ctx.stream_handle != NULL) {
|
||||||
device_lock(g_ctx.i2s_dev);
|
audio_stream_close(g_ctx.stream_handle);
|
||||||
i2s_controller_reset(g_ctx.i2s_dev);
|
g_ctx.stream_handle = NULL;
|
||||||
device_unlock(g_ctx.i2s_dev);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (g_ctx.input_buf) {
|
if (g_ctx.input_buf) {
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32s3
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.mp3player
|
||||||
platforms=esp32s3
|
app.version.name=1.0.0
|
||||||
[app]
|
app.version.code=1
|
||||||
id=one.tactility.mp3player
|
app.name=MP3 Player
|
||||||
versionName=1.0.0
|
|
||||||
versionCode=1
|
|
||||||
name=MP3 Player
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.mystifydemo
|
||||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
app.version.name=0.6.0
|
||||||
[app]
|
app.version.code=6
|
||||||
id=one.tactility.mystifydemo
|
app.name=Mystify Demo
|
||||||
versionName=0.3.0
|
|
||||||
versionCode=3
|
|
||||||
name=Mystify Demo
|
|
||||||
|
|||||||
@@ -10,5 +10,5 @@ idf_component_register(
|
|||||||
../../../Libraries/TactilityCpp/Include
|
../../../Libraries/TactilityCpp/Include
|
||||||
../../../Libraries/GameKit/Include
|
../../../Libraries/GameKit/Include
|
||||||
../../../Libraries/SfxEngine/Include
|
../../../Libraries/SfxEngine/Include
|
||||||
REQUIRES TactilitySDK esp_driver_i2s esp_driver_gpio
|
REQUIRES TactilitySDK
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
#include "DungeonModel.h"
|
#include "DungeonModel.h"
|
||||||
|
|
||||||
#ifndef LV_ABS
|
#ifndef LV_ABS
|
||||||
#define LV_ABS(x) ((x) > 0 ? (x) : -(x))
|
#define LV_ABS(x) ((x) > 0 ? (x) : -(x))
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
namespace PocketDungeon {
|
namespace PocketDungeon {
|
||||||
|
|
||||||
uint32_t DungeonModel::nextRandom() {
|
uint32_t DungeonModel::nextRandom() {
|
||||||
@@ -11,48 +9,75 @@ uint32_t DungeonModel::nextRandom() {
|
|||||||
return (seed >> 16u) & 0x7FFFu;
|
return (seed >> 16u) & 0x7FFFu;
|
||||||
}
|
}
|
||||||
|
|
||||||
void DungeonModel::reset(uint32_t seedValue) {
|
void DungeonModel::reset(uint32_t seedValue, bool tutorials) {
|
||||||
seed = seedValue;
|
seed = seedValue;
|
||||||
state = DungeonState {};
|
state = DungeonState {};
|
||||||
state.floor = 1;
|
state.floor = tutorials ? 0 : 2;
|
||||||
state.hp = 5;
|
state.hp = 5;
|
||||||
state.gold = 0;
|
state.gold = 0;
|
||||||
state.gameOver = false;
|
state.gameOver = false;
|
||||||
generateFloor();
|
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) {
|
void DungeonModel::addEnemy(EntityType type, GameKit::GridPos pos, int8_t hp) {
|
||||||
if (state.entityCount >= MAX_ENTITIES) return;
|
if (state.entityCount >= MAX_ENTITIES) return;
|
||||||
state.entities[state.entityCount++] = Entity { type, pos, hp, true };
|
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() {
|
void DungeonModel::generateFloor() {
|
||||||
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y) {
|
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 x = 0; x < DUNGEON_COLS; ++x) {
|
for (uint8_t x = 0; x < DUNGEON_COLS; ++x) {
|
||||||
const bool edge = (x == 0 || y == 0 || x == DUNGEON_COLS - 1 || y == DUNGEON_ROWS - 1);
|
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.tiles[y][x] = edge ? Tile::Wall : Tile::Floor;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
state.playerPos = {1, 1};
|
state.playerPos = {1, 1};
|
||||||
state.entityCount = 0;
|
state.entityCount = 0;
|
||||||
state.tiles[2][4] = Tile::Wall;
|
state.tiles[2][4] = Tile::Wall;
|
||||||
state.tiles[3][4] = Tile::Wall;
|
state.tiles[3][4] = Tile::Wall;
|
||||||
state.tiles[5][2 + (state.floor % 2)] = Tile::Treasure;
|
state.tiles[5][2 + (state.floor % 2)] = Tile::Treasure;
|
||||||
state.tiles[5][7] = Tile::Stairs;
|
state.tiles[5][7] = Tile::Stairs;
|
||||||
if (state.floor > 1) {
|
if (state.floor > 2) {
|
||||||
auto x = static_cast<uint8_t>(2 + (nextRandom() % 5));
|
auto x = static_cast<uint8_t>(2 + (nextRandom() % 5));
|
||||||
auto y = static_cast<uint8_t>(1 + (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;
|
if (!(x == 1 && y == 1) && !(x == 7 && y == 5)) state.tiles[y][x] = Tile::Wall;
|
||||||
}
|
}
|
||||||
addEnemy(EntityType::Slime, {5, 2}, 1 + static_cast<int8_t>(state.floor / 3));
|
uint16_t realFloor = state.floor - 1;
|
||||||
|
addEnemy(EntityType::Slime, {5, 2}, 1 + static_cast<int8_t>(realFloor / 3));
|
||||||
addEnemy(EntityType::Bat, {3, 5}, 1);
|
addEnemy(EntityType::Bat, {3, 5}, 1);
|
||||||
if (state.floor >= 3) addEnemy(EntityType::Slime, {7, 4}, 2);
|
if (realFloor >= 3) addEnemy(EntityType::Slime, {7, 4}, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
int DungeonModel::findEnemyAt(GameKit::GridPos pos) const {
|
int DungeonModel::findEnemyAt(GameKit::GridPos pos) const {
|
||||||
for (uint8_t i = 0; i < state.entityCount; ++i) {
|
for (uint8_t i = 0; i < state.entityCount; ++i) {
|
||||||
const auto& entity = state.entities[i];
|
const auto& e = state.entities[i];
|
||||||
if (entity.alive && entity.pos == pos) return i;
|
if (e.alive && e.pos == pos) return i;
|
||||||
}
|
}
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
@@ -70,11 +95,8 @@ MoveResult DungeonModel::movePlayer(int dx, int dy) {
|
|||||||
if (enemyIdx >= 0) {
|
if (enemyIdx >= 0) {
|
||||||
Entity& enemy = state.entities[enemyIdx];
|
Entity& enemy = state.entities[enemyIdx];
|
||||||
enemy.hp -= 1;
|
enemy.hp -= 1;
|
||||||
if (enemy.hp <= 0) {
|
if (enemy.hp <= 0) { enemy.alive = false; state.gold += enemy.type == EntityType::Bat ? 2 : 1; }
|
||||||
enemy.alive = false;
|
if (!isTutorial()) moveMonsters();
|
||||||
state.gold += enemy.type == EntityType::Bat ? 2 : 1;
|
|
||||||
}
|
|
||||||
moveMonsters();
|
|
||||||
if (state.hp <= 0) { state.gameOver = true; return MoveResult::Dead; }
|
if (state.hp <= 0) { state.gameOver = true; return MoveResult::Dead; }
|
||||||
return MoveResult::Attacked;
|
return MoveResult::Attacked;
|
||||||
}
|
}
|
||||||
@@ -87,15 +109,19 @@ MoveResult DungeonModel::movePlayer(int dx, int dy) {
|
|||||||
result = MoveResult::Treasure;
|
result = MoveResult::Treasure;
|
||||||
} else if (tile == Tile::Stairs) {
|
} else if (tile == Tile::Stairs) {
|
||||||
state.floor += 1;
|
state.floor += 1;
|
||||||
if (state.floor > bestFloor) bestFloor = state.floor;
|
if (state.floor >= 2) {
|
||||||
if (state.gold > bestGold) bestGold = state.gold;
|
if (state.floor - 1 > bestFloor) bestFloor = state.floor - 1;
|
||||||
|
if (state.gold > bestGold) bestGold = state.gold;
|
||||||
|
}
|
||||||
generateFloor();
|
generateFloor();
|
||||||
return MoveResult::Stairs;
|
return MoveResult::Stairs;
|
||||||
}
|
}
|
||||||
moveMonsters();
|
if (!isTutorial()) moveMonsters();
|
||||||
if (state.hp <= 0) { state.gameOver = true; return MoveResult::Dead; }
|
if (state.hp <= 0) { state.gameOver = true; return MoveResult::Dead; }
|
||||||
if (state.floor > bestFloor) bestFloor = state.floor;
|
if (state.floor >= 2) {
|
||||||
if (state.gold > bestGold) bestGold = state.gold;
|
if (state.floor - 1 > bestFloor) bestFloor = state.floor - 1;
|
||||||
|
if (state.gold > bestGold) bestGold = state.gold;
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,26 +131,19 @@ void DungeonModel::moveMonsters() {
|
|||||||
if (!enemy.alive) continue;
|
if (!enemy.alive) continue;
|
||||||
const int16_t dx = state.playerPos.x - enemy.pos.x;
|
const int16_t dx = state.playerPos.x - enemy.pos.x;
|
||||||
const int16_t dy = state.playerPos.y - enemy.pos.y;
|
const int16_t dy = state.playerPos.y - enemy.pos.y;
|
||||||
if ((dx == 0 && (dy == 1 || dy == -1)) || (dy == 0 && (dx == 1 || dx == -1))) {
|
if ((dx == 0 && (dy == 1 || dy == -1)) || (dy == 0 && (dx == 1 || dx == -1))) { state.hp -= 1; continue; }
|
||||||
state.hp -= 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
GameKit::GridPos target = enemy.pos;
|
GameKit::GridPos target = enemy.pos;
|
||||||
if (enemy.type == EntityType::Bat && (nextRandom() % 3 == 0)) {
|
if (enemy.type == EntityType::Bat && (nextRandom() % 3 == 0)) {
|
||||||
const int dirs[4][2] = {{1,0},{-1,0},{0,1},{0,-1}};
|
const int dirs[4][2] = {{1,0},{-1,0},{0,1},{0,-1}};
|
||||||
const int* dir = dirs[nextRandom() % 4];
|
const int* dir = dirs[nextRandom() % 4];
|
||||||
target.x += dir[0];
|
target.x += dir[0]; target.y += dir[1];
|
||||||
target.y += dir[1];
|
|
||||||
} else if (LV_ABS(dx) > LV_ABS(dy)) {
|
} else if (LV_ABS(dx) > LV_ABS(dy)) {
|
||||||
target.x += dx > 0 ? 1 : -1;
|
target.x += dx > 0 ? 1 : -1;
|
||||||
} else if (dy != 0) {
|
} else if (dy != 0) {
|
||||||
target.y += dy > 0 ? 1 : -1;
|
target.y += dy > 0 ? 1 : -1;
|
||||||
}
|
}
|
||||||
if (target == state.playerPos) {
|
if (target == state.playerPos) state.hp -= 1;
|
||||||
state.hp -= 1;
|
else if (isWalkable(target) && findEnemyAt(target) < 0) enemy.pos = target;
|
||||||
} else if (isWalkable(target) && findEnemyAt(target) < 0) {
|
|
||||||
enemy.pos = target;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
|
||||||
namespace PocketDungeon {
|
namespace PocketDungeon {
|
||||||
|
|
||||||
static constexpr uint8_t DUNGEON_COLS = 9;
|
static constexpr uint8_t DUNGEON_COLS = 9;
|
||||||
static constexpr uint8_t DUNGEON_ROWS = 7;
|
static constexpr uint8_t DUNGEON_ROWS = 7;
|
||||||
|
static constexpr uint8_t TUTORIAL_ROWS = 2;
|
||||||
static constexpr uint8_t MAX_ENTITIES = 8;
|
static constexpr uint8_t MAX_ENTITIES = 8;
|
||||||
|
|
||||||
enum class Tile : uint8_t { Floor, Wall, Stairs, Treasure };
|
enum class Tile : uint8_t { Floor, Wall, Stairs, Treasure };
|
||||||
@@ -25,10 +25,11 @@ struct DungeonState {
|
|||||||
Entity entities[MAX_ENTITIES] {};
|
Entity entities[MAX_ENTITIES] {};
|
||||||
uint8_t entityCount = 0;
|
uint8_t entityCount = 0;
|
||||||
GameKit::GridPos playerPos {1, 1};
|
GameKit::GridPos playerPos {1, 1};
|
||||||
uint16_t floor = 1;
|
uint16_t floor = 0; // 0,1 tutorial, 2+ real
|
||||||
uint16_t gold = 0;
|
uint16_t gold = 0;
|
||||||
int8_t hp = 5;
|
int8_t hp = 5;
|
||||||
bool gameOver = false;
|
bool gameOver = false;
|
||||||
|
uint8_t renderRows = DUNGEON_ROWS;
|
||||||
};
|
};
|
||||||
|
|
||||||
class DungeonModel {
|
class DungeonModel {
|
||||||
@@ -42,9 +43,11 @@ class DungeonModel {
|
|||||||
int findEnemyAt(GameKit::GridPos pos) const;
|
int findEnemyAt(GameKit::GridPos pos) const;
|
||||||
bool isWalkable(GameKit::GridPos pos) const;
|
bool isWalkable(GameKit::GridPos pos) const;
|
||||||
void moveMonsters();
|
void moveMonsters();
|
||||||
|
void generateTutorialFloor(uint8_t tId);
|
||||||
|
void clearAllTiles();
|
||||||
|
|
||||||
public:
|
public:
|
||||||
void reset(uint32_t seedValue = 0xC0FFEE);
|
void reset(uint32_t seedValue = 0xC0FFEE, bool tutorials = true);
|
||||||
void generateFloor();
|
void generateFloor();
|
||||||
MoveResult movePlayer(int dx, int dy);
|
MoveResult movePlayer(int dx, int dy);
|
||||||
|
|
||||||
@@ -52,6 +55,9 @@ public:
|
|||||||
uint16_t getBestFloor() const { return bestFloor; }
|
uint16_t getBestFloor() const { return bestFloor; }
|
||||||
uint16_t getBestGold() const { return bestGold; }
|
uint16_t getBestGold() const { return bestGold; }
|
||||||
void setBests(uint16_t floor, uint16_t gold) { bestFloor = floor; bestGold = gold; }
|
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
|
} // namespace PocketDungeon
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ void DungeonRenderer::create(lv_obj_t* parent) {
|
|||||||
lv_obj_remove_flag(root, LV_OBJ_FLAG_SCROLLABLE);
|
lv_obj_remove_flag(root, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
status = lv_label_create(root);
|
status = lv_label_create(root);
|
||||||
lv_obj_set_style_text_color(status, lv_color_hex(0xF2E8D0), LV_PART_MAIN);
|
lv_obj_set_style_text_color(status, lv_color_hex(0xF2E8D0), LV_PART_MAIN);
|
||||||
lv_obj_align(status, LV_ALIGN_TOP_MID, 0, 8);
|
lv_obj_align(status, LV_ALIGN_TOP_MID, 0, 6);
|
||||||
const lv_coord_t screenW = lv_display_get_horizontal_resolution(nullptr);
|
const lv_coord_t screenW = lv_display_get_horizontal_resolution(nullptr);
|
||||||
const lv_coord_t screenH = lv_display_get_vertical_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);
|
const uint16_t maxCellW = static_cast<uint16_t>((screenW - 22) / DUNGEON_COLS);
|
||||||
@@ -48,8 +48,7 @@ lv_color_t DungeonRenderer::tileColor(Tile tile) {
|
|||||||
case Tile::Wall: return lv_color_hex(COLOR_WALL);
|
case Tile::Wall: return lv_color_hex(COLOR_WALL);
|
||||||
case Tile::Stairs: return lv_color_hex(COLOR_STAIRS);
|
case Tile::Stairs: return lv_color_hex(COLOR_STAIRS);
|
||||||
case Tile::Treasure: return lv_color_hex(COLOR_TREASURE);
|
case Tile::Treasure: return lv_color_hex(COLOR_TREASURE);
|
||||||
case Tile::Floor:
|
case Tile::Floor: default: return lv_color_hex(COLOR_FLOOR);
|
||||||
default: return lv_color_hex(COLOR_FLOOR);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,48 +64,64 @@ lv_color_t DungeonRenderer::entityColor(EntityType type) {
|
|||||||
switch (type) {
|
switch (type) {
|
||||||
case EntityType::Player: return lv_color_hex(COLOR_PLAYER);
|
case EntityType::Player: return lv_color_hex(COLOR_PLAYER);
|
||||||
case EntityType::Bat: return lv_color_hex(COLOR_BAT);
|
case EntityType::Bat: return lv_color_hex(COLOR_BAT);
|
||||||
case EntityType::Slime:
|
case EntityType::Slime: default: return lv_color_hex(COLOR_SLIME);
|
||||||
default: return lv_color_hex(COLOR_SLIME);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void DungeonRenderer::clearEntityLabels() {
|
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) {
|
for (uint8_t x = 0; x < DUNGEON_COLS; ++x) {
|
||||||
lv_label_set_text(labels[y][x], "");
|
lv_label_set_text(labels[y][x], "");
|
||||||
lv_obj_set_style_text_color(labels[y][x], lv_color_white(), LV_PART_MAIN);
|
lv_obj_set_style_text_color(labels[y][x], lv_color_white(), LV_PART_MAIN);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void DungeonRenderer::render(const DungeonModel& model, MoveResult lastResult) {
|
void DungeonRenderer::render(const DungeonModel& model, MoveResult lastResult) {
|
||||||
const DungeonState& state = model.getState();
|
const DungeonState& state = model.getState();
|
||||||
char buf[96];
|
char buf[96];
|
||||||
std::snprintf(buf, sizeof(buf), "F%u HP:%d Gold:%u Best:%u/%u", state.floor, state.hp, state.gold, model.getBestFloor(), model.getBestGold());
|
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());
|
||||||
|
}
|
||||||
lv_label_set_text(status, buf);
|
lv_label_set_text(status, buf);
|
||||||
clearEntityLabels();
|
clearEntityLabels();
|
||||||
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y) {
|
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) {
|
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];
|
const Tile tile = state.tiles[y][x];
|
||||||
lv_obj_set_style_bg_color(cells[y][x], tileColor(tile), LV_PART_MAIN);
|
lv_obj_set_style_bg_color(cells[y][x], tileColor(tile), LV_PART_MAIN);
|
||||||
lv_label_set_text(labels[y][x], tileText(tile));
|
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) {
|
for (uint8_t i = 0; i < state.entityCount; ++i) {
|
||||||
const Entity& entity = state.entities[i];
|
const Entity& e = state.entities[i];
|
||||||
if (!entity.alive) continue;
|
if (!e.alive) continue;
|
||||||
const char* symbol = entity.type == EntityType::Bat ? "b" : "s";
|
const char* sym = e.type == EntityType::Bat ? "b" : "s";
|
||||||
lv_label_set_text(labels[entity.pos.y][entity.pos.x], symbol);
|
lv_label_set_text(labels[e.pos.y][e.pos.x], sym);
|
||||||
lv_obj_set_style_text_color(labels[entity.pos.y][entity.pos.x], entityColor(entity.type), LV_PART_MAIN);
|
lv_obj_set_style_text_color(labels[e.pos.y][e.pos.x], entityColor(e.type), LV_PART_MAIN);
|
||||||
}
|
}
|
||||||
lv_label_set_text(labels[state.playerPos.y][state.playerPos.x], "@");
|
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);
|
lv_obj_set_style_text_color(labels[state.playerPos.y][state.playerPos.x], lv_color_hex(COLOR_PLAYER), LV_PART_MAIN);
|
||||||
const char* text = "Move with arrows, swipe, or tap a direction";
|
|
||||||
|
const char* text = model.isTutorial() ? (model.tutorialId()==0 ? "TUT1: @ to >" : "TUT2: Kill b, $ , >") : "Arrows/Swipe/Tap";
|
||||||
switch (lastResult) {
|
switch (lastResult) {
|
||||||
case MoveResult::Blocked: text = "A wall blocks the way"; break;
|
case MoveResult::Blocked: text = "Wall blocks"; break;
|
||||||
case MoveResult::Attacked: text = "You strike the monster"; break;
|
case MoveResult::Attacked: text = "You strike!"; break;
|
||||||
case MoveResult::Treasure: text = "Treasure found!"; break;
|
case MoveResult::Treasure: text = "Treasure! +5"; break;
|
||||||
case MoveResult::Stairs: text = "Down the stairs..."; break;
|
case MoveResult::Stairs: text = model.isTutorial() ? "Tutorial done!" : "Down..."; break;
|
||||||
case MoveResult::Dead: text = "Game over - tap to restart"; break;
|
case MoveResult::Dead: text = "Game over - tap to restart"; break;
|
||||||
default: break;
|
default: break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
#include "PocketDungeon.h"
|
#include "PocketDungeon.h"
|
||||||
#include <tt_lvgl_toolbar.h>
|
#include <cstdio>
|
||||||
|
extern "C" {
|
||||||
|
#include <tt_app.h>
|
||||||
|
}
|
||||||
|
|
||||||
namespace PocketDungeon {
|
namespace PocketDungeon {
|
||||||
|
|
||||||
@@ -7,19 +10,26 @@ static constexpr uint32_t TICK_MS = 200;
|
|||||||
static constexpr const char* PREF_BEST_FLOOR = "bestFloor";
|
static constexpr const char* PREF_BEST_FLOOR = "bestFloor";
|
||||||
static constexpr const char* PREF_BEST_GOLD = "bestGold";
|
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) {
|
void PocketDungeon::onShow(AppHandle app, lv_obj_t* parent) {
|
||||||
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
|
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
lv_obj_set_style_pad_all(parent, 0, LV_PART_MAIN);
|
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;
|
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.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());
|
model.reset(lv_tick_get(), true);
|
||||||
if (sfx == nullptr) {
|
if (sfx == nullptr) {
|
||||||
sfx = new SfxEngine();
|
sfx = new SfxEngine();
|
||||||
if (sfx->start()) sfx->applyVolumePreset(SfxEngine::VolumePreset::Quiet);
|
if (sfx->start()) sfx->applyVolumePreset(SfxEngine::VolumePreset::Quiet);
|
||||||
}
|
}
|
||||||
|
phase = AppPhase::Intro;
|
||||||
onEnter(parent);
|
onEnter(parent);
|
||||||
loop.start(TICK_MS, this);
|
loop.start(TICK_MS, this);
|
||||||
|
(void) app;
|
||||||
}
|
}
|
||||||
|
|
||||||
void PocketDungeon::onHide(AppHandle app) {
|
void PocketDungeon::onHide(AppHandle app) {
|
||||||
@@ -27,63 +37,300 @@ void PocketDungeon::onHide(AppHandle app) {
|
|||||||
loop.stop();
|
loop.stop();
|
||||||
onExit();
|
onExit();
|
||||||
saveBests();
|
saveBests();
|
||||||
if (sfx != nullptr) {
|
if (sfx) { sfx->stop(); delete sfx; sfx = nullptr; }
|
||||||
sfx->stop();
|
|
||||||
delete sfx;
|
|
||||||
sfx = nullptr;
|
|
||||||
}
|
|
||||||
root = nullptr;
|
root = nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
void PocketDungeon::onEnter(lv_obj_t* sceneRoot) {
|
void PocketDungeon::onEnter(lv_obj_t* sceneRoot) {
|
||||||
renderer.create(sceneRoot);
|
root = sceneRoot;
|
||||||
GameKit::attachInput(sceneRoot, this);
|
GameKit::attachInput(root, this);
|
||||||
|
showIntro();
|
||||||
lastResult = MoveResult::None;
|
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();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
void PocketDungeon::onExit() {}
|
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::onInput(const GameKit::InputEvent& input) {
|
void PocketDungeon::onInput(const GameKit::InputEvent& input) {
|
||||||
int dx = 0;
|
if (input.kind == GameKit::InputKind::Cancel || input.kind == GameKit::InputKind::Menu) { exitGame(); return; }
|
||||||
int dy = 0;
|
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;
|
||||||
switch (input.kind) {
|
switch (input.kind) {
|
||||||
case GameKit::InputKind::Up: dy = -1; break;
|
case GameKit::InputKind::Up: dy=-1; break;
|
||||||
case GameKit::InputKind::Down: dy = 1; break;
|
case GameKit::InputKind::Down: dy=1; break;
|
||||||
case GameKit::InputKind::Left: dx = -1; break;
|
case GameKit::InputKind::Left: dx=-1; break;
|
||||||
case GameKit::InputKind::Right: dx = 1; break;
|
case GameKit::InputKind::Right: dx=1; break;
|
||||||
default: break;
|
default: break;
|
||||||
}
|
}
|
||||||
if (model.getState().gameOver) {
|
if (model.getState().gameOver) {
|
||||||
model.reset(lv_tick_get());
|
if (dx!=0||dy!=0||input.kind==GameKit::InputKind::Confirm||input.kind==GameKit::InputKind::Touch) {
|
||||||
lastResult = MoveResult::None;
|
model.reset(lv_tick_get(), true); lastResult=MoveResult::None; render();
|
||||||
render();
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (dx != 0 || dy != 0) {
|
if (dx!=0||dy!=0) {
|
||||||
lastResult = model.movePlayer(dx, dy);
|
lastResult=model.movePlayer(dx,dy);
|
||||||
playResultSound(lastResult);
|
playResultSound(lastResult);
|
||||||
if (lastResult == MoveResult::Dead) saveBests();
|
if (lastResult==MoveResult::Dead) saveBests();
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void PocketDungeon::onTick(const GameKit::Tick& tick) {
|
void PocketDungeon::onTick(const GameKit::Tick& tick){ (void)tick; }
|
||||||
(void) tick;
|
void PocketDungeon::render(){
|
||||||
}
|
if (phase==AppPhase::Intro||phase==AppPhase::Paused) return;
|
||||||
|
if (!gameContainer) return;
|
||||||
void PocketDungeon::render() {
|
|
||||||
renderer.render(model, lastResult);
|
renderer.render(model, lastResult);
|
||||||
}
|
}
|
||||||
|
void PocketDungeon::saveBests(){
|
||||||
void PocketDungeon::saveBests() {
|
|
||||||
prefs.putInt(PREF_BEST_FLOOR, model.getBestFloor());
|
prefs.putInt(PREF_BEST_FLOOR, model.getBestFloor());
|
||||||
prefs.putInt(PREF_BEST_GOLD, model.getBestGold());
|
prefs.putInt(PREF_BEST_GOLD, model.getBestGold());
|
||||||
}
|
}
|
||||||
|
void PocketDungeon::playResultSound(MoveResult r){
|
||||||
void PocketDungeon::playResultSound(MoveResult result) {
|
if (!sfx) return;
|
||||||
if (sfx == nullptr) return;
|
switch(r){
|
||||||
switch (result) {
|
|
||||||
case MoveResult::Attacked: sfx->play(SfxId::Hurt); break;
|
case MoveResult::Attacked: sfx->play(SfxId::Hurt); break;
|
||||||
case MoveResult::Treasure: sfx->play(SfxId::Coin); break;
|
case MoveResult::Treasure: sfx->play(SfxId::Coin); break;
|
||||||
case MoveResult::Stairs: sfx->play(SfxId::Warp); break;
|
case MoveResult::Stairs: sfx->play(SfxId::Warp); break;
|
||||||
|
|||||||
@@ -9,8 +9,15 @@
|
|||||||
|
|
||||||
namespace PocketDungeon {
|
namespace PocketDungeon {
|
||||||
|
|
||||||
|
enum class AppPhase : uint8_t { Intro, Playing, Paused };
|
||||||
|
|
||||||
class PocketDungeon final : public App, public GameKit::Scene {
|
class PocketDungeon final : public App, public GameKit::Scene {
|
||||||
lv_obj_t* root = nullptr;
|
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::Loop loop;
|
||||||
GameKit::Prefs prefs {"PocketDungeon"};
|
GameKit::Prefs prefs {"PocketDungeon"};
|
||||||
DungeonModel model;
|
DungeonModel model;
|
||||||
@@ -20,6 +27,20 @@ class PocketDungeon final : public App, public GameKit::Scene {
|
|||||||
|
|
||||||
void playResultSound(MoveResult result);
|
void playResultSound(MoveResult result);
|
||||||
void saveBests();
|
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:
|
public:
|
||||||
void onShow(AppHandle app, lv_obj_t* parent) override;
|
void onShow(AppHandle app, lv_obj_t* parent) override;
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32s3,esp32p4
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.pocketdungeon
|
||||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
app.version.name=0.1.2
|
||||||
[app]
|
app.version.code=3
|
||||||
id=one.tactility.pocketdungeon
|
app.name=Pocket Dungeon
|
||||||
versionName=0.1.0
|
app.description=Tiny paper-inspired dungeon crawler - tutorial + fullscreen
|
||||||
versionCode=1
|
app.flags=HideStatusBar
|
||||||
name=Pocket Dungeon
|
|
||||||
description=Tiny paper-inspired dungeon crawler
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32s3
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.reynabot
|
||||||
platforms=esp32s3
|
app.version.name=1.0.0
|
||||||
[app]
|
app.version.code=1
|
||||||
id=one.tactility.reynabot
|
app.name=ReynaBot
|
||||||
versionName=1.0.0
|
|
||||||
versionCode=1
|
|
||||||
name=ReynaBot
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.serialconsole
|
||||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
app.version.name=0.7.0
|
||||||
[app]
|
app.version.code=7
|
||||||
id=one.tactility.serialconsole
|
app.name=Serial Console
|
||||||
versionName=0.4.0
|
|
||||||
versionCode=4
|
|
||||||
name=Serial Console
|
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.snake
|
||||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
app.version.name=0.8.0
|
||||||
[app]
|
app.version.code=8
|
||||||
id=one.tactility.snake
|
app.name=Snake
|
||||||
versionName=0.5.0
|
app.description=Classic Snake game
|
||||||
versionCode=5
|
|
||||||
name=Snake
|
|
||||||
description=Classic Snake game
|
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ void TamaTac::onShow(AppHandle context, lv_obj_t* parent) {
|
|||||||
if (sfxEngine == nullptr) {
|
if (sfxEngine == nullptr) {
|
||||||
sfxEngine = new SfxEngine();
|
sfxEngine = new SfxEngine();
|
||||||
sfxEngine->start();
|
sfxEngine->start();
|
||||||
sfxEngine->applyVolumePreset(SfxEngine::VolumePreset::Normal);
|
|
||||||
|
|
||||||
// Load settings
|
// Load settings
|
||||||
bool soundEnabled;
|
bool soundEnabled;
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.tamatac
|
||||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
app.version.name=0.4.0
|
||||||
[app]
|
app.version.code=4
|
||||||
id=one.tactility.tamatac
|
app.name=TamaTac
|
||||||
versionName=0.1.0
|
app.description=Virtual pet inspired by Tamagotchi. Only runs on devices with PSRAM.
|
||||||
versionCode=1
|
|
||||||
name=TamaTac
|
|
||||||
description=Virtual pet inspired by Tamagotchi. Only runs on devices with PSRAM.
|
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.todolist
|
||||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
app.version.name=0.5.0
|
||||||
[app]
|
app.version.code=5
|
||||||
id=one.tactility.todolist
|
app.name=Todo List
|
||||||
versionName=0.2.0
|
app.description=Simple task list manager
|
||||||
versionCode=2
|
|
||||||
name=Todo List
|
|
||||||
description=Simple task list manager
|
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
[manifest]
|
manifest.version=0.2
|
||||||
version=0.1
|
target.sdk=0.8.0-dev
|
||||||
[target]
|
target.platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||||
sdk=0.7.0-dev
|
app.id=one.tactility.twoeleven
|
||||||
platforms=esp32,esp32s3,esp32c6,esp32p4
|
app.version.name=0.7.0
|
||||||
[app]
|
app.version.code=7
|
||||||
id=one.tactility.twoeleven
|
app.name=2048
|
||||||
versionName=0.4.0
|
app.description=A fun, customizable 2048 sliding tile game for tactility!\nSlide tiles to combine numbers and reach 2048.\nChoose grid sizes: 3x3 (easy), 4x4 (classic), 5x5, or 6x6 (expert).
|
||||||
versionCode=4
|
|
||||||
name=2048
|
|
||||||
description=A fun, customizable 2048 sliding tile game for tactility!\nSlide tiles to combine numbers and reach 2048.\nChoose grid sizes: 3x3 (easy), 4x4 (classic), 5x5, or 6x6 (expert).
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.20)
|
||||||
|
|
||||||
|
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||||
|
|
||||||
|
if (DEFINED ENV{TACTILITY_SDK_PATH})
|
||||||
|
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
|
||||||
|
else()
|
||||||
|
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
|
||||||
|
message(WARNING "⚠️ TACTILITY_SDK_PATH environment variable is not set, defaulting to ${TACTILITY_SDK_PATH}")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
|
||||||
|
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
|
||||||
|
|
||||||
|
project(VoiceRecorder)
|
||||||
|
tactility_project(VoiceRecorder)
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
|
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
|
||||||
|
|
||||||
idf_component_register(
|
idf_component_register(
|
||||||
SRCS ${SOURCE_FILES}
|
SRCS ${SOURCE_FILES}
|
||||||
REQUIRES TactilitySDK esp_http_client json
|
REQUIRES TactilitySDK
|
||||||
)
|
)
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Voice Recorder App for Tactility OS
|
* Voice Recorder App for Tactility OS
|
||||||
*
|
*
|
||||||
* Records voice memos (16kHz 16-bit Mono WAV format) to the SD card (/sdcard/memos/)
|
* Records voice memos (16kHz 16-bit Mono WAV) to /sdcard/memos/
|
||||||
* and plays them back.
|
* Uses audio-stream API with resampling (native codec 44100 -> app 16000)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#include <tt_app.h>
|
#include <tt_app.h>
|
||||||
#include <tt_lvgl.h>
|
#include <tt_lvgl.h>
|
||||||
#include <tt_lvgl_toolbar.h>
|
#include <tt_lvgl_toolbar.h>
|
||||||
|
|
||||||
#include <tactility/device.h>
|
#include <tactility/device.h>
|
||||||
#include <tactility/drivers/i2s_controller.h>
|
#include <tactility/drivers/audio_stream.h>
|
||||||
|
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
@@ -42,7 +41,9 @@ typedef enum {
|
|||||||
} AudioState;
|
} AudioState;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
struct Device* i2s_dev;
|
struct Device* stream_dev;
|
||||||
|
AudioStreamHandle input_handle;
|
||||||
|
AudioStreamHandle output_handle;
|
||||||
uint8_t* audio_buf;
|
uint8_t* audio_buf;
|
||||||
AudioState state;
|
AudioState state;
|
||||||
int volume;
|
int volume;
|
||||||
@@ -65,19 +66,19 @@ typedef struct {
|
|||||||
} AppCtx;
|
} AppCtx;
|
||||||
|
|
||||||
typedef struct __attribute__((packed)) {
|
typedef struct __attribute__((packed)) {
|
||||||
char riff[4]; // "RIFF"
|
char riff[4];
|
||||||
uint32_t overall_size; // file size - 8
|
uint32_t overall_size;
|
||||||
char wave[4]; // "WAVE"
|
char wave[4];
|
||||||
char fmt_chunk_marker[4]; // "fmt "
|
char fmt_chunk_marker[4];
|
||||||
uint32_t length_of_fmt; // 16 for PCM
|
uint32_t length_of_fmt;
|
||||||
uint16_t format_type; // 1 for PCM
|
uint16_t format_type;
|
||||||
uint16_t channels; // 1 for mono
|
uint16_t channels;
|
||||||
uint32_t sample_rate; // 16000
|
uint32_t sample_rate;
|
||||||
uint32_t byterate; // sample_rate * channels * (bits_per_sample / 8)
|
uint32_t byterate;
|
||||||
uint16_t block_align; // channels * (bits_per_sample / 8)
|
uint16_t block_align;
|
||||||
uint16_t bits_per_sample; // 16
|
uint16_t bits_per_sample;
|
||||||
char data_chunk_header[4]; // "data"
|
char data_chunk_header[4];
|
||||||
uint32_t data_size; // data size in bytes
|
uint32_t data_size;
|
||||||
} WavHeader;
|
} WavHeader;
|
||||||
|
|
||||||
static AppCtx g_ctx;
|
static AppCtx g_ctx;
|
||||||
@@ -87,6 +88,20 @@ static void update_ui(AppCtx* ctx);
|
|||||||
static void refresh_memo_list(AppCtx* ctx);
|
static void refresh_memo_list(AppCtx* ctx);
|
||||||
static void on_memo_selected(lv_event_t* e);
|
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 ─── */
|
/* ─── 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) {
|
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;
|
WavHeader header;
|
||||||
@@ -114,10 +129,8 @@ static void get_next_memo_filename(char* out_filename, size_t max_len) {
|
|||||||
struct tm timeinfo;
|
struct tm timeinfo;
|
||||||
localtime_r(&rawtime, &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);
|
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);
|
snprintf(out_filename, max_len, "memo_%s.wav", datetime_buf);
|
||||||
|
|
||||||
char filepath[256];
|
char filepath[256];
|
||||||
@@ -125,24 +138,21 @@ static void get_next_memo_filename(char* out_filename, size_t max_len) {
|
|||||||
|
|
||||||
struct stat st;
|
struct stat st;
|
||||||
if (stat(filepath, &st) != 0) {
|
if (stat(filepath, &st) != 0) {
|
||||||
// File does not exist, safe to use
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// File exists, let's search for a unique name by appending _N
|
|
||||||
int suffix = 1;
|
int suffix = 1;
|
||||||
while (1) {
|
while (1) {
|
||||||
snprintf(out_filename, max_len, "memo_%s_%d.wav", datetime_buf, suffix);
|
snprintf(out_filename, max_len, "memo_%s_%d.wav", datetime_buf, suffix);
|
||||||
snprintf(filepath, sizeof(filepath), "/sdcard/memos/%s", out_filename);
|
snprintf(filepath, sizeof(filepath), "/sdcard/memos/%s", out_filename);
|
||||||
if (stat(filepath, &st) != 0) {
|
if (stat(filepath, &st) != 0) {
|
||||||
// Found a unique name!
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
suffix++;
|
suffix++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Record Task ─── */
|
/* ─── Record Task (audio-stream input, resampled to 16k mono) ─── */
|
||||||
static void record_task(void* arg) {
|
static void record_task(void* arg) {
|
||||||
AppCtx* ctx = (AppCtx*)arg;
|
AppCtx* ctx = (AppCtx*)arg;
|
||||||
char filepath[256];
|
char filepath[256];
|
||||||
@@ -164,29 +174,24 @@ static void record_task(void* arg) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write default header
|
|
||||||
write_wav_header(f, SAMPLE_RATE, BITS_PER_SAMPLE, 1, 0);
|
write_wav_header(f, SAMPLE_RATE, BITS_PER_SAMPLE, 1, 0);
|
||||||
|
|
||||||
/* Configure I2S for recording - RLCD fix: ES7210 outputs stereo (2 mics)
|
// Open input stream via audio-stream (resampler provides 16k mono regardless of codec native rate)
|
||||||
* even when we want mono file. Working MicroPython uses I2S.STEREO for RX
|
struct AudioStreamConfig in_cfg = {
|
||||||
* 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,
|
.sample_rate = SAMPLE_RATE,
|
||||||
.bits_per_sample = BITS_PER_SAMPLE,
|
.bits_per_sample = BITS_PER_SAMPLE,
|
||||||
.channel_left = 0,
|
.channels = 1
|
||||||
.channel_right = 1 // STEREO for ES7210 dual mic - we downmix to mono below
|
|
||||||
};
|
};
|
||||||
|
|
||||||
device_lock(ctx->i2s_dev);
|
error_t err = audio_stream_open_input(ctx->stream_dev, &in_cfg, &ctx->input_handle);
|
||||||
error_t err = i2s_controller_set_config(ctx->i2s_dev, &cfg);
|
if (err == ERROR_NONE) {
|
||||||
device_unlock(ctx->i2s_dev);
|
// 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%%");
|
||||||
|
}
|
||||||
if (err != ERROR_NONE) {
|
if (err != ERROR_NONE) {
|
||||||
ESP_LOGE(TAG, "Failed to set I2S config: %d", err);
|
ESP_LOGE(TAG, "audio_stream_open_input failed: %d", err);
|
||||||
fclose(f);
|
fclose(f);
|
||||||
unlink(filepath);
|
unlink(filepath);
|
||||||
tt_lvgl_lock(portMAX_DELAY);
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
@@ -199,68 +204,47 @@ static void record_task(void* arg) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Recording started -> %s", filepath);
|
ESP_LOGI(TAG, "Recording started -> %s (via audio-stream %u Hz)", filepath, (unsigned)SAMPLE_RATE);
|
||||||
|
|
||||||
size_t total_written = 0;
|
size_t total_written = 0;
|
||||||
uint32_t start_time = xTaskGetTickCount();
|
uint32_t start_time = xTaskGetTickCount();
|
||||||
|
|
||||||
while (ctx->state == STATE_RECORDING) {
|
while (ctx->state == STATE_RECORDING) {
|
||||||
size_t bytes_read = 0;
|
size_t bytes_read = 0;
|
||||||
err = i2s_controller_read(ctx->i2s_dev, ctx->audio_buf, AUDIO_BUF_SIZE, &bytes_read, pdMS_TO_TICKS(100));
|
err = audio_stream_read(ctx->input_handle, ctx->audio_buf, AUDIO_BUF_SIZE, &bytes_read, pdMS_TO_TICKS(200));
|
||||||
if (err == ERROR_NONE && bytes_read > 0) {
|
if (err == ERROR_NONE && bytes_read > 0) {
|
||||||
// ES7210 stereo -> mono downmix: extract left channel (every other 16-bit sample)
|
// Data already 16k mono from audio-stream resampler, no downmix needed
|
||||||
// Working MP: for i in 0..bytes_read step 4: mono[j:j+2]=buffer[i:i+2]
|
size_t written = fwrite(ctx->audio_buf, 1, bytes_read, f);
|
||||||
// CHUNK_BYTES=1024 mono, but we read AUDIO_BUF_SIZE stereo (4096) then downmix to half
|
if (written != bytes_read) {
|
||||||
size_t mono_bytes = bytes_read / 2;
|
ESP_LOGE(TAG, "SD write error: wrote %d of %d", (int)written, (int)bytes_read);
|
||||||
// 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;
|
break;
|
||||||
}
|
}
|
||||||
total_written += written;
|
total_written += written;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update elapsed time in UI
|
|
||||||
uint32_t elapsed_sec = (xTaskGetTickCount() - start_time) / configTICK_RATE_HZ;
|
uint32_t elapsed_sec = (xTaskGetTickCount() - start_time) / configTICK_RATE_HZ;
|
||||||
char status_buf[64];
|
char status_buf[64];
|
||||||
snprintf(status_buf, sizeof(status_buf), "🔴 Recording... %02u:%02u", (unsigned)(elapsed_sec / 60), (unsigned)(elapsed_sec % 60));
|
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);
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
lv_label_set_text(ctx->lbl_status, status_buf);
|
lv_label_set_text(ctx->lbl_status, status_buf);
|
||||||
lv_bar_set_value(ctx->bar_progress, bar_val % 100, LV_ANIM_OFF);
|
lv_bar_set_value(ctx->bar_progress, (int)((elapsed_sec * 100 / 300)) % 100, LV_ANIM_OFF);
|
||||||
tt_lvgl_unlock();
|
tt_lvgl_unlock();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rewrite WAV header with actual recorded size
|
audio_stream_close(ctx->input_handle);
|
||||||
|
ctx->input_handle = NULL;
|
||||||
|
|
||||||
fseek(f, 0, SEEK_SET);
|
fseek(f, 0, SEEK_SET);
|
||||||
write_wav_header(f, SAMPLE_RATE, BITS_PER_SAMPLE, 1, total_written);
|
write_wav_header(f, SAMPLE_RATE, BITS_PER_SAMPLE, 1, total_written);
|
||||||
fclose(f);
|
fclose(f);
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Recording finished: %u bytes", (unsigned)total_written);
|
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);
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
ctx->state = STATE_IDLE;
|
ctx->state = STATE_IDLE;
|
||||||
refresh_memo_list(ctx);
|
refresh_memo_list(ctx);
|
||||||
|
|
||||||
// Select the new file automatically
|
|
||||||
ctx->selected_index = -1;
|
ctx->selected_index = -1;
|
||||||
for (int i = 0; i < ctx->memo_count; i++) {
|
for (int i = 0; i < ctx->memo_count; i++) {
|
||||||
if (strcmp(ctx->memos[i], filename) == 0) {
|
if (strcmp(ctx->memos[i], filename) == 0) {
|
||||||
@@ -268,7 +252,7 @@ static void record_task(void* arg) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
update_ui(ctx);
|
update_ui(ctx);
|
||||||
tt_lvgl_unlock();
|
tt_lvgl_unlock();
|
||||||
|
|
||||||
@@ -276,7 +260,7 @@ static void record_task(void* arg) {
|
|||||||
vTaskDelete(NULL);
|
vTaskDelete(NULL);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Playback Task ─── */
|
/* ─── Playback Task (audio-stream output with resampling) ─── */
|
||||||
static void play_task(void* arg) {
|
static void play_task(void* arg) {
|
||||||
AppCtx* ctx = (AppCtx*)arg;
|
AppCtx* ctx = (AppCtx*)arg;
|
||||||
if (ctx->selected_index < 0 || ctx->selected_index >= ctx->memo_count) {
|
if (ctx->selected_index < 0 || ctx->selected_index >= ctx->memo_count) {
|
||||||
@@ -305,7 +289,6 @@ static void play_task(void* arg) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read and parse WAV header
|
|
||||||
WavHeader header;
|
WavHeader header;
|
||||||
if (fread(&header, 1, sizeof(WavHeader), f) != sizeof(WavHeader)) {
|
if (fread(&header, 1, sizeof(WavHeader), f) != sizeof(WavHeader)) {
|
||||||
ESP_LOGE(TAG, "Failed to read WAV header");
|
ESP_LOGE(TAG, "Failed to read WAV header");
|
||||||
@@ -320,7 +303,6 @@ static void play_task(void* arg) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify file structure
|
|
||||||
if (memcmp(header.riff, "RIFF", 4) != 0 || memcmp(header.wave, "WAVE", 4) != 0) {
|
if (memcmp(header.riff, "RIFF", 4) != 0 || memcmp(header.wave, "WAVE", 4) != 0) {
|
||||||
ESP_LOGE(TAG, "Invalid WAV format");
|
ESP_LOGE(TAG, "Invalid WAV format");
|
||||||
fclose(f);
|
fclose(f);
|
||||||
@@ -334,21 +316,16 @@ static void play_task(void* arg) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Configure I2S based on file metadata */
|
/* Open output stream via audio-stream (resampler converts file rate -> native 44100) */
|
||||||
struct I2sConfig cfg = {
|
struct AudioStreamConfig out_cfg = {
|
||||||
.communication_format = I2S_FORMAT_STAND_I2S,
|
|
||||||
.sample_rate = header.sample_rate,
|
.sample_rate = header.sample_rate,
|
||||||
.bits_per_sample = header.bits_per_sample,
|
.bits_per_sample = header.bits_per_sample,
|
||||||
.channel_left = 0,
|
.channels = header.channels
|
||||||
.channel_right = (header.channels == 2) ? 1 : I2S_CHANNEL_NONE
|
|
||||||
};
|
};
|
||||||
|
|
||||||
device_lock(ctx->i2s_dev);
|
error_t err = audio_stream_open_output(ctx->stream_dev, &out_cfg, &ctx->output_handle);
|
||||||
error_t err = i2s_controller_set_config(ctx->i2s_dev, &cfg);
|
|
||||||
device_unlock(ctx->i2s_dev);
|
|
||||||
|
|
||||||
if (err != ERROR_NONE) {
|
if (err != ERROR_NONE) {
|
||||||
ESP_LOGE(TAG, "Failed to set I2S config for playback: %d", err);
|
ESP_LOGE(TAG, "audio_stream_open_output failed for playback: %d (rate=%u)", err, (unsigned)header.sample_rate);
|
||||||
fclose(f);
|
fclose(f);
|
||||||
tt_lvgl_lock(portMAX_DELAY);
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
ctx->state = STATE_IDLE;
|
ctx->state = STATE_IDLE;
|
||||||
@@ -360,7 +337,7 @@ static void play_task(void* arg) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Playing: %s, rate=%u Hz, channels=%u, size=%u bytes",
|
ESP_LOGI(TAG, "Playing via audio-stream: %s, rate=%u Hz, ch=%u, size=%u",
|
||||||
filepath, (unsigned)header.sample_rate, header.channels, (unsigned)header.data_size);
|
filepath, (unsigned)header.sample_rate, header.channels, (unsigned)header.data_size);
|
||||||
|
|
||||||
size_t total_played = 0;
|
size_t total_played = 0;
|
||||||
@@ -371,40 +348,35 @@ static void play_task(void* arg) {
|
|||||||
fseek(f, sizeof(WavHeader), SEEK_SET);
|
fseek(f, sizeof(WavHeader), SEEK_SET);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool i2s_configured = true;
|
bool out_open = true;
|
||||||
|
|
||||||
while (total_played < data_size && ctx->state != STATE_IDLE) {
|
while (total_played < data_size && ctx->state != STATE_IDLE) {
|
||||||
if (ctx->state == STATE_PAUSED) {
|
if (ctx->state == STATE_PAUSED) {
|
||||||
if (i2s_configured) {
|
if (out_open) {
|
||||||
// Reset I2S controller to stop DMA immediately when pausing
|
audio_stream_close(ctx->output_handle);
|
||||||
device_lock(ctx->i2s_dev);
|
ctx->output_handle = NULL;
|
||||||
i2s_controller_reset(ctx->i2s_dev);
|
out_open = false;
|
||||||
device_unlock(ctx->i2s_dev);
|
|
||||||
i2s_configured = false;
|
|
||||||
}
|
}
|
||||||
vTaskDelay(pdMS_TO_TICKS(50));
|
vTaskDelay(pdMS_TO_TICKS(50));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!i2s_configured) {
|
if (!out_open) {
|
||||||
// Reconfigure I2S when resuming
|
err = audio_stream_open_output(ctx->stream_dev, &out_cfg, &ctx->output_handle);
|
||||||
device_lock(ctx->i2s_dev);
|
|
||||||
err = i2s_controller_set_config(ctx->i2s_dev, &cfg);
|
|
||||||
device_unlock(ctx->i2s_dev);
|
|
||||||
if (err != ERROR_NONE) {
|
if (err != ERROR_NONE) {
|
||||||
ESP_LOGE(TAG, "Failed to reconfigure I2S on resume: %d", err);
|
ESP_LOGE(TAG, "Failed to reopen audio_stream on resume: %d", err);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
i2s_configured = true;
|
out_open = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
size_t to_read = (data_size - total_played < CHUNK_BYTES) ? (data_size - total_played) : CHUNK_BYTES;
|
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);
|
size_t read_bytes = fread(ctx->audio_buf, 1, to_read, f);
|
||||||
if (read_bytes == 0) {
|
if (read_bytes == 0) {
|
||||||
break; // EOF
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scaling Volume
|
// Volume scaling
|
||||||
int vol = ctx->volume;
|
int vol = ctx->volume;
|
||||||
int16_t* samples_ptr = (int16_t*)ctx->audio_buf;
|
int16_t* samples_ptr = (int16_t*)ctx->audio_buf;
|
||||||
size_t sample_count = read_bytes / sizeof(int16_t);
|
size_t sample_count = read_bytes / sizeof(int16_t);
|
||||||
@@ -417,22 +389,19 @@ static void play_task(void* arg) {
|
|||||||
bool write_err = false;
|
bool write_err = false;
|
||||||
while (offset < read_bytes && ctx->state == STATE_PLAYING) {
|
while (offset < read_bytes && ctx->state == STATE_PLAYING) {
|
||||||
size_t written = 0;
|
size_t written = 0;
|
||||||
err = i2s_controller_write(ctx->i2s_dev, ctx->audio_buf + offset, read_bytes - offset, &written, pdMS_TO_TICKS(100));
|
err = audio_stream_write(ctx->output_handle, ctx->audio_buf + offset, read_bytes - offset, &written, pdMS_TO_TICKS(500));
|
||||||
if (err != ERROR_NONE || written == 0) {
|
if (err != ERROR_NONE || written == 0) {
|
||||||
ESP_LOGE(TAG, "I2S playback write error: %d", err);
|
ESP_LOGE(TAG, "audio_stream write error: %d", err);
|
||||||
write_err = true;
|
write_err = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
offset += written;
|
offset += written;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (write_err) {
|
if (write_err) break;
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
total_played += read_bytes;
|
total_played += read_bytes;
|
||||||
|
|
||||||
// UI progress update
|
|
||||||
int pct = (int)((total_played * 100) / data_size);
|
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 bytes_per_sec = header.sample_rate * header.channels * (header.bits_per_sample / 8);
|
||||||
uint32_t elapsed_sec = total_played / bytes_per_sec;
|
uint32_t elapsed_sec = total_played / bytes_per_sec;
|
||||||
@@ -452,10 +421,10 @@ static void play_task(void* arg) {
|
|||||||
fclose(f);
|
fclose(f);
|
||||||
ESP_LOGI(TAG, "Playback task complete");
|
ESP_LOGI(TAG, "Playback task complete");
|
||||||
|
|
||||||
// Reset I2S controller to stop/release DMA channel and prevent looping noise
|
if (out_open && ctx->output_handle) {
|
||||||
device_lock(ctx->i2s_dev);
|
audio_stream_close(ctx->output_handle);
|
||||||
i2s_controller_reset(ctx->i2s_dev);
|
ctx->output_handle = NULL;
|
||||||
device_unlock(ctx->i2s_dev);
|
}
|
||||||
|
|
||||||
tt_lvgl_lock(portMAX_DELAY);
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
ctx->state = STATE_IDLE;
|
ctx->state = STATE_IDLE;
|
||||||
@@ -470,12 +439,12 @@ static void play_task(void* arg) {
|
|||||||
static void on_record_click(lv_event_t* e) {
|
static void on_record_click(lv_event_t* e) {
|
||||||
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
|
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
|
||||||
if (ctx->state != STATE_IDLE) return;
|
if (ctx->state != STATE_IDLE) return;
|
||||||
if (ctx->i2s_dev == NULL) return;
|
if (ctx->stream_dev == NULL) return;
|
||||||
|
|
||||||
ctx->state = STATE_RECORDING;
|
ctx->state = STATE_RECORDING;
|
||||||
update_ui(ctx);
|
update_ui(ctx);
|
||||||
|
|
||||||
xTaskCreate(record_task, "voice_rec", 4096, ctx, 5, &ctx->task_handle);
|
xTaskCreate(record_task, "voice_rec", 8192, ctx, 5, &ctx->task_handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void on_play_pause_click(lv_event_t* e) {
|
static void on_play_pause_click(lv_event_t* e) {
|
||||||
@@ -485,7 +454,7 @@ static void on_play_pause_click(lv_event_t* e) {
|
|||||||
if (ctx->state == STATE_IDLE) {
|
if (ctx->state == STATE_IDLE) {
|
||||||
ctx->state = STATE_PLAYING;
|
ctx->state = STATE_PLAYING;
|
||||||
update_ui(ctx);
|
update_ui(ctx);
|
||||||
xTaskCreate(play_task, "voice_play", 4096, ctx, 5, &ctx->task_handle);
|
xTaskCreate(play_task, "voice_play", 8192, ctx, 5, &ctx->task_handle);
|
||||||
} else if (ctx->state == STATE_PLAYING) {
|
} else if (ctx->state == STATE_PLAYING) {
|
||||||
ctx->state = STATE_PAUSED;
|
ctx->state = STATE_PAUSED;
|
||||||
update_ui(ctx);
|
update_ui(ctx);
|
||||||
@@ -523,14 +492,12 @@ static void on_memo_selected(lv_event_t* e) {
|
|||||||
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
|
AppCtx* ctx = (AppCtx*)lv_event_get_user_data(e);
|
||||||
lv_obj_t* btn = lv_event_get_target(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);
|
uint32_t child_cnt = lv_obj_get_child_cnt(ctx->lst_memos);
|
||||||
for (uint32_t i = 0; i < child_cnt; i++) {
|
for (uint32_t i = 0; i < child_cnt; i++) {
|
||||||
lv_obj_t* child = lv_obj_get_child(ctx->lst_memos, i);
|
lv_obj_t* child = lv_obj_get_child(ctx->lst_memos, i);
|
||||||
lv_obj_clear_state(child, LV_STATE_CHECKED);
|
lv_obj_clear_state(child, LV_STATE_CHECKED);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply check state to the clicked memo
|
|
||||||
lv_obj_add_state(btn, LV_STATE_CHECKED);
|
lv_obj_add_state(btn, LV_STATE_CHECKED);
|
||||||
|
|
||||||
const char* text = lv_list_get_btn_text(ctx->lst_memos, btn);
|
const char* text = lv_list_get_btn_text(ctx->lst_memos, btn);
|
||||||
@@ -568,7 +535,6 @@ static void refresh_memo_list(AppCtx* ctx) {
|
|||||||
}
|
}
|
||||||
closedir(dir);
|
closedir(dir);
|
||||||
|
|
||||||
// Simple bubble sort
|
|
||||||
for (int i = 0; i < ctx->memo_count - 1; i++) {
|
for (int i = 0; i < ctx->memo_count - 1; i++) {
|
||||||
for (int j = 0; j < ctx->memo_count - i - 1; j++) {
|
for (int j = 0; j < ctx->memo_count - i - 1; j++) {
|
||||||
if (strcmp(ctx->memos[j], ctx->memos[j + 1]) > 0) {
|
if (strcmp(ctx->memos[j], ctx->memos[j + 1]) > 0) {
|
||||||
@@ -580,7 +546,6 @@ static void refresh_memo_list(AppCtx* ctx) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Populate UI List
|
|
||||||
for (int i = 0; i < ctx->memo_count; i++) {
|
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_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);
|
lv_obj_add_event_cb(btn, on_memo_selected, LV_EVENT_CLICKED, ctx);
|
||||||
@@ -624,7 +589,7 @@ static void update_ui(AppCtx* ctx) {
|
|||||||
if (ctx->selected_index >= 0 && ctx->selected_index < ctx->memo_count) {
|
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_play_pause, LV_STATE_DISABLED);
|
||||||
lv_obj_clear_state(ctx->btn_delete, LV_STATE_DISABLED);
|
lv_obj_clear_state(ctx->btn_delete, LV_STATE_DISABLED);
|
||||||
|
|
||||||
char status_buf[128];
|
char status_buf[128];
|
||||||
snprintf(status_buf, sizeof(status_buf), "Selected: %s", ctx->memos[ctx->selected_index]);
|
snprintf(status_buf, sizeof(status_buf), "Selected: %s", ctx->memos[ctx->selected_index]);
|
||||||
lv_label_set_text(ctx->lbl_status, status_buf);
|
lv_label_set_text(ctx->lbl_status, status_buf);
|
||||||
@@ -647,115 +612,100 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
|||||||
g_ctx.volume = 80;
|
g_ctx.volume = 80;
|
||||||
g_ctx.selected_index = -1;
|
g_ctx.selected_index = -1;
|
||||||
|
|
||||||
// Create the memos storage directory if missing
|
|
||||||
mkdir("/sdcard/memos", 0755);
|
mkdir("/sdcard/memos", 0755);
|
||||||
|
|
||||||
// Allocate audio buffer
|
|
||||||
g_ctx.audio_buf = (uint8_t*)malloc(AUDIO_BUF_SIZE);
|
g_ctx.audio_buf = (uint8_t*)malloc(AUDIO_BUF_SIZE);
|
||||||
if (g_ctx.audio_buf == NULL) {
|
if (g_ctx.audio_buf == NULL) {
|
||||||
ESP_LOGE(TAG, "Failed to allocate audio buffer");
|
ESP_LOGE(TAG, "Failed to allocate audio buffer");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Locate I2S hardware channel
|
if (!find_audio_stream_device(&g_ctx)) {
|
||||||
g_ctx.i2s_dev = device_find_by_name("i2s0");
|
ESP_LOGE(TAG, "audio-stream device not found!");
|
||||||
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_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, app);
|
||||||
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
|
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
|
||||||
|
|
||||||
// Premium Player Card
|
|
||||||
lv_obj_t* card = lv_obj_create(parent);
|
lv_obj_t* card = lv_obj_create(parent);
|
||||||
lv_obj_set_size(card, lv_pct(95), lv_pct(88));
|
lv_obj_set_size(card, lv_pct(95), lv_pct(88));
|
||||||
lv_obj_align(card, LV_ALIGN_CENTER, 0, 12);
|
lv_obj_align(card, LV_ALIGN_CENTER, 0, 12);
|
||||||
lv_obj_set_style_radius(card, 15, 0);
|
lv_obj_set_style_radius(card, 15, 0);
|
||||||
lv_obj_set_style_bg_color(card, lv_color_hex(0x1E1E2E), 0); // mocha base
|
lv_obj_set_style_bg_color(card, lv_color_hex(0x1E1E2E), 0);
|
||||||
lv_obj_set_style_border_color(card, lv_color_hex(0x313244), 0); // mocha surface0
|
lv_obj_set_style_border_color(card, lv_color_hex(0x313244), 0);
|
||||||
lv_obj_set_style_border_width(card, 2, 0);
|
lv_obj_set_style_border_width(card, 2, 0);
|
||||||
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
|
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_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_all(card, 10, 0);
|
||||||
lv_obj_set_style_pad_gap(card, 8, 0);
|
lv_obj_set_style_pad_gap(card, 8, 0);
|
||||||
|
|
||||||
// Status Area
|
|
||||||
g_ctx.lbl_status = lv_label_create(card);
|
g_ctx.lbl_status = lv_label_create(card);
|
||||||
lv_obj_set_width(g_ctx.lbl_status, lv_pct(95));
|
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_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); // mocha text
|
lv_obj_set_style_text_color(g_ctx.lbl_status, lv_color_hex(0xCDD6F4), 0);
|
||||||
lv_label_set_text(g_ctx.lbl_status, "Scanning memos...");
|
lv_label_set_text(g_ctx.lbl_status, "Scanning memos...");
|
||||||
|
|
||||||
// Progress Bar
|
|
||||||
g_ctx.bar_progress = lv_bar_create(card);
|
g_ctx.bar_progress = lv_bar_create(card);
|
||||||
lv_obj_set_size(g_ctx.bar_progress, lv_pct(90), 8);
|
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_range(g_ctx.bar_progress, 0, 100);
|
||||||
lv_bar_set_value(g_ctx.bar_progress, 0, LV_ANIM_OFF);
|
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); // mocha surface1
|
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); // mocha blue
|
lv_obj_set_style_bg_color(g_ctx.bar_progress, lv_color_hex(0x89B4FA), LV_PART_INDICATOR);
|
||||||
|
|
||||||
// Memos List
|
|
||||||
g_ctx.lst_memos = lv_list_create(card);
|
g_ctx.lst_memos = lv_list_create(card);
|
||||||
lv_obj_set_size(g_ctx.lst_memos, lv_pct(95), 85);
|
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); // mocha mantle
|
lv_obj_set_style_bg_color(g_ctx.lst_memos, lv_color_hex(0x181825), 0);
|
||||||
lv_obj_set_style_border_color(g_ctx.lst_memos, lv_color_hex(0x313244), 0);
|
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_border_width(g_ctx.lst_memos, 1, 0);
|
||||||
lv_obj_set_style_radius(g_ctx.lst_memos, 8, 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_t* ctrl_box = lv_obj_create(card);
|
||||||
lv_obj_remove_style_all(ctrl_box);
|
lv_obj_remove_style_all(ctrl_box);
|
||||||
lv_obj_set_size(ctrl_box, lv_pct(95), 40);
|
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_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);
|
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);
|
g_ctx.btn_record = lv_btn_create(ctrl_box);
|
||||||
lv_obj_set_size(g_ctx.btn_record, 45, 32);
|
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_radius(g_ctx.btn_record, 8, 0);
|
||||||
lv_obj_set_style_bg_color(g_ctx.btn_record, lv_color_hex(0xF38BA8), 0); // mocha red
|
lv_obj_set_style_bg_color(g_ctx.btn_record, lv_color_hex(0xF38BA8), 0);
|
||||||
lv_obj_set_style_text_color(g_ctx.btn_record, lv_color_hex(0x11111B), 0);
|
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_obj_t* lbl_rec = lv_label_create(g_ctx.btn_record);
|
||||||
lv_label_set_text(lbl_rec, LV_SYMBOL_AUDIO);
|
lv_label_set_text(lbl_rec, LV_SYMBOL_AUDIO);
|
||||||
lv_obj_center(lbl_rec);
|
lv_obj_center(lbl_rec);
|
||||||
lv_obj_add_event_cb(g_ctx.btn_record, on_record_click, LV_EVENT_CLICKED, &g_ctx);
|
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);
|
g_ctx.btn_play_pause = lv_btn_create(ctrl_box);
|
||||||
lv_obj_set_size(g_ctx.btn_play_pause, 45, 32);
|
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_radius(g_ctx.btn_play_pause, 8, 0);
|
||||||
lv_obj_set_style_bg_color(g_ctx.btn_play_pause, lv_color_hex(0x89B4FA), 0); // mocha blue
|
lv_obj_set_style_bg_color(g_ctx.btn_play_pause, lv_color_hex(0x89B4FA), 0);
|
||||||
lv_obj_set_style_text_color(g_ctx.btn_play_pause, lv_color_hex(0x11111B), 0);
|
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_obj_t* lbl_play = lv_label_create(g_ctx.btn_play_pause);
|
||||||
lv_label_set_text(lbl_play, LV_SYMBOL_PLAY);
|
lv_label_set_text(lbl_play, LV_SYMBOL_PLAY);
|
||||||
lv_obj_center(lbl_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);
|
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);
|
g_ctx.btn_stop = lv_btn_create(ctrl_box);
|
||||||
lv_obj_set_size(g_ctx.btn_stop, 45, 32);
|
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_radius(g_ctx.btn_stop, 8, 0);
|
||||||
lv_obj_set_style_bg_color(g_ctx.btn_stop, lv_color_hex(0xBAC2DE), 0); // mocha subtext1
|
lv_obj_set_style_bg_color(g_ctx.btn_stop, lv_color_hex(0xBAC2DE), 0);
|
||||||
lv_obj_set_style_text_color(g_ctx.btn_stop, lv_color_hex(0x11111B), 0);
|
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_obj_t* lbl_stop = lv_label_create(g_ctx.btn_stop);
|
||||||
lv_label_set_text(lbl_stop, LV_SYMBOL_STOP);
|
lv_label_set_text(lbl_stop, LV_SYMBOL_STOP);
|
||||||
lv_obj_center(lbl_stop);
|
lv_obj_center(lbl_stop);
|
||||||
lv_obj_add_event_cb(g_ctx.btn_stop, on_stop_click, LV_EVENT_CLICKED, &g_ctx);
|
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);
|
g_ctx.btn_delete = lv_btn_create(ctrl_box);
|
||||||
lv_obj_set_size(g_ctx.btn_delete, 45, 32);
|
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_radius(g_ctx.btn_delete, 8, 0);
|
||||||
lv_obj_set_style_bg_color(g_ctx.btn_delete, lv_color_hex(0x74C7EC), 0); // mocha sapphire
|
lv_obj_set_style_bg_color(g_ctx.btn_delete, lv_color_hex(0x74C7EC), 0);
|
||||||
lv_obj_set_style_text_color(g_ctx.btn_delete, lv_color_hex(0x11111B), 0);
|
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_obj_t* lbl_del = lv_label_create(g_ctx.btn_delete);
|
||||||
lv_label_set_text(lbl_del, LV_SYMBOL_TRASH);
|
lv_label_set_text(lbl_del, LV_SYMBOL_TRASH);
|
||||||
lv_obj_center(lbl_del);
|
lv_obj_center(lbl_del);
|
||||||
lv_obj_add_event_cb(g_ctx.btn_delete, on_delete_click, LV_EVENT_CLICKED, &g_ctx);
|
lv_obj_add_event_cb(g_ctx.btn_delete, on_delete_click, LV_EVENT_CLICKED, &g_ctx);
|
||||||
|
|
||||||
// Initialize list and control state
|
if (g_ctx.stream_dev == NULL) {
|
||||||
if (g_ctx.i2s_dev == NULL) {
|
lv_label_set_text(g_ctx.lbl_status, "ERROR: audio-stream missing");
|
||||||
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_record, LV_STATE_DISABLED);
|
||||||
lv_obj_add_state(g_ctx.btn_play_pause, 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);
|
lv_obj_add_state(g_ctx.btn_stop, LV_STATE_DISABLED);
|
||||||
@@ -778,16 +728,17 @@ static void onHideApp(AppHandle app, void* data) {
|
|||||||
g_ctx.state = STATE_IDLE;
|
g_ctx.state = STATE_IDLE;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for task completion
|
|
||||||
while (g_ctx.task_handle != NULL) {
|
while (g_ctx.task_handle != NULL) {
|
||||||
vTaskDelay(pdMS_TO_TICKS(10));
|
vTaskDelay(pdMS_TO_TICKS(10));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset I2S to ensure DMA channel is stopped
|
if (g_ctx.input_handle) {
|
||||||
if (g_ctx.i2s_dev != NULL) {
|
audio_stream_close(g_ctx.input_handle);
|
||||||
device_lock(g_ctx.i2s_dev);
|
g_ctx.input_handle = NULL;
|
||||||
i2s_controller_reset(g_ctx.i2s_dev);
|
}
|
||||||
device_unlock(g_ctx.i2s_dev);
|
if (g_ctx.output_handle) {
|
||||||
|
audio_stream_close(g_ctx.output_handle);
|
||||||
|
g_ctx.output_handle = NULL;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (g_ctx.audio_buf != NULL) {
|
if (g_ctx.audio_buf != NULL) {
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
manifest.version=0.2
|
||||||
|
target.sdk=0.8.0-dev
|
||||||
|
target.platforms=esp32s3
|
||||||
|
app.id=one.tactility.voicerecorder
|
||||||
|
app.version.name=1.0.0
|
||||||
|
app.version.code=1
|
||||||
|
app.name=Voice Recorder
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
SHELL_COLOR_RED = "\033[91m"
|
||||||
|
SHELL_COLOR_ORANGE = "\033[93m"
|
||||||
|
SHELL_COLOR_RESET = "\033[m"
|
||||||
|
|
||||||
|
def print_warning(message):
|
||||||
|
print(f"{SHELL_COLOR_ORANGE}WARNING: {message}{SHELL_COLOR_RESET}")
|
||||||
|
|
||||||
|
def print_error(message):
|
||||||
|
print(f"{SHELL_COLOR_RED}ERROR: {message}{SHELL_COLOR_RESET}")
|
||||||
|
|
||||||
|
def print_help():
|
||||||
|
print("Usage: python upload-app-files.py [path] [sdkVersion] [cloudflareAccountId] [cloudflareTokenName] [cloudflareTokenValue]")
|
||||||
|
print("")
|
||||||
|
print("Options:")
|
||||||
|
print(" --index-only Upload only apps.json")
|
||||||
|
|
||||||
|
def exit_with_error(message):
|
||||||
|
print_error(message)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def main(path: str, sdk_version: str, cloudflare_account_id, cloudflare_token_name: str, cloudflare_token_value: str, index_only: bool):
|
||||||
|
if not os.path.exists(path):
|
||||||
|
exit_with_error(f"Path not found: {path}")
|
||||||
|
s3 = boto3.client(
|
||||||
|
service_name="s3",
|
||||||
|
endpoint_url=f"https://{cloudflare_account_id}.r2.cloudflarestorage.com",
|
||||||
|
aws_access_key_id=cloudflare_token_name,
|
||||||
|
aws_secret_access_key=cloudflare_token_value,
|
||||||
|
region_name="auto"
|
||||||
|
)
|
||||||
|
files_to_upload = os.listdir(path)
|
||||||
|
if index_only:
|
||||||
|
files_to_upload = [f for f in files_to_upload if f == 'apps.json']
|
||||||
|
else:
|
||||||
|
# Ensure apps.json is uploaded last so it never references files that
|
||||||
|
# haven't finished uploading yet.
|
||||||
|
files_to_upload.sort(key=lambda f: f == 'apps.json')
|
||||||
|
counter = 1
|
||||||
|
total = len(files_to_upload)
|
||||||
|
for file_name in files_to_upload:
|
||||||
|
object_path = f"apps/{sdk_version}/{file_name}"
|
||||||
|
print(f"[{counter}/{total}] Uploading {file_name} to {object_path}")
|
||||||
|
file_path = os.path.join(path, file_name)
|
||||||
|
try:
|
||||||
|
s3.upload_file(file_path, "tactility", object_path)
|
||||||
|
except Exception as e:
|
||||||
|
exit_with_error(f"Failed to upload {file_name}: {str(e)}")
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("Tactility CDN Apps Uploader")
|
||||||
|
if "--help" in sys.argv:
|
||||||
|
print_help()
|
||||||
|
sys.exit()
|
||||||
|
# Argument validation
|
||||||
|
if len(sys.argv) < 6:
|
||||||
|
print_help()
|
||||||
|
sys.exit(1)
|
||||||
|
main(
|
||||||
|
path=sys.argv[1],
|
||||||
|
sdk_version=sys.argv[2],
|
||||||
|
cloudflare_account_id=sys.argv[3],
|
||||||
|
cloudflare_token_name=sys.argv[4],
|
||||||
|
cloudflare_token_value=sys.argv[5],
|
||||||
|
index_only="--index-only" in sys.argv
|
||||||
|
)
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <tactility/device.h>
|
#include <tactility/device.h>
|
||||||
|
#include <tactility/drivers/audio_stream.h>
|
||||||
#include "freertos/FreeRTOS.h"
|
#include "freertos/FreeRTOS.h"
|
||||||
#include "freertos/task.h"
|
#include "freertos/task.h"
|
||||||
#include "freertos/queue.h"
|
#include "freertos/queue.h"
|
||||||
@@ -277,7 +278,8 @@ private:
|
|||||||
// State
|
// State
|
||||||
//--------------------------------------------------------------------------
|
//--------------------------------------------------------------------------
|
||||||
|
|
||||||
Device* i2sDevice_ = nullptr;
|
Device* streamDevice_ = nullptr;
|
||||||
|
AudioStreamHandle streamHandle_ = nullptr;
|
||||||
TaskHandle_t task_ = nullptr;
|
TaskHandle_t task_ = nullptr;
|
||||||
SemaphoreHandle_t stopSemaphore_ = nullptr; // Signaled when audio task exits
|
SemaphoreHandle_t stopSemaphore_ = nullptr; // Signaled when audio task exits
|
||||||
QueueHandle_t msgQueue_ = nullptr;
|
QueueHandle_t msgQueue_ = nullptr;
|
||||||
|
|||||||
@@ -35,11 +35,8 @@ if (!engine->start()) {
|
|||||||
ESP_LOGE(TAG, "Failed to start SfxEngine");
|
ESP_LOGE(TAG, "Failed to start SfxEngine");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
engine->applyVolumePreset(SfxEngine::VolumePreset::Normal);
|
|
||||||
|
|
||||||
engine->play(SfxId::Coin); // Predefined SFX
|
engine->play(SfxId::Coin); // Predefined SFX
|
||||||
engine->playNote(0, 60, 200); // Manual: voice 0, C4, 200ms
|
engine->playNote(0, 60, 200); // Manual: voice 0, C4, 200ms
|
||||||
engine->setVolume(0.7f); // Volume control
|
|
||||||
|
|
||||||
engine->stop();
|
engine->stop();
|
||||||
delete engine;
|
delete engine;
|
||||||
@@ -87,9 +84,11 @@ idf_component_register(
|
|||||||
- `void stopVoice(voice)` - Stop specific voice
|
- `void stopVoice(voice)` - Stop specific voice
|
||||||
|
|
||||||
### Settings
|
### Settings
|
||||||
- `void setVolume(float)` - Master volume (0.0-1.0, exponential curve)
|
|
||||||
- `void setEnabled(bool)` - Mute/unmute
|
- `void setEnabled(bool)` - Mute/unmute
|
||||||
- `void applyVolumePreset(VolumePreset)` - Apply Quiet/Normal/Loud preset (configures volume, gate, normalization)
|
|
||||||
|
Loudness is controlled by the system output volume (set via the audio_stream device / Settings UI),
|
||||||
|
not by SfxEngine itself -- a fixed app-side gain on top of hardware attenuation gets swamped at low
|
||||||
|
system volumes, so there's no separate volume control here.
|
||||||
|
|
||||||
### Mixing (consistent with SoundEngine)
|
### Mixing (consistent with SoundEngine)
|
||||||
- `void setPolyphonicGateEnabled(bool)` - Soft gate when multiple voices clip (default: on)
|
- `void setPolyphonicGateEnabled(bool)` - Soft gate when multiple voices clip (default: on)
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
#include "SfxEngine.h"
|
#include "SfxEngine.h"
|
||||||
#include "SfxDefinitions.h"
|
#include "SfxDefinitions.h"
|
||||||
|
|
||||||
#include <tactility/drivers/i2s_controller.h>
|
#include <tactility/device.h>
|
||||||
|
#include <tactility/drivers/audio_stream.h>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include "esp_log.h"
|
#include "esp_log.h"
|
||||||
@@ -424,7 +425,7 @@ void SfxEngine::audioTaskFunc(void* param) {
|
|||||||
size_t written;
|
size_t written;
|
||||||
QueueMsg msg;
|
QueueMsg msg;
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Audio task started");
|
ESP_LOGI(TAG, "Audio task started (audio-stream)");
|
||||||
|
|
||||||
while (self->running_) {
|
while (self->running_) {
|
||||||
// Process queued messages (non-blocking)
|
// Process queued messages (non-blocking)
|
||||||
@@ -463,19 +464,26 @@ void SfxEngine::audioTaskFunc(void* param) {
|
|||||||
// Fill audio buffer (member buffer to avoid stack pressure)
|
// Fill audio buffer (member buffer to avoid stack pressure)
|
||||||
self->fillStereoBuffer(self->audioBuffer_, BUFFER_SAMPLES);
|
self->fillStereoBuffer(self->audioBuffer_, BUFFER_SAMPLES);
|
||||||
|
|
||||||
// Write to I2S
|
// Write via audio-stream (resampled to native codec rate, e.g. 44100)
|
||||||
error_t error = i2s_controller_write(self->i2sDevice_, self->audioBuffer_,
|
if (self->streamHandle_ == nullptr) {
|
||||||
sizeof(self->audioBuffer_), &written, pdMS_TO_TICKS(100));
|
vTaskDelay(pdMS_TO_TICKS(10));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
error_t error = audio_stream_write(self->streamHandle_, self->audioBuffer_,
|
||||||
|
sizeof(self->audioBuffer_), &written, pdMS_TO_TICKS(200));
|
||||||
if (error != ERROR_NONE) {
|
if (error != ERROR_NONE) {
|
||||||
ESP_LOGE(TAG, "I2S write error");
|
ESP_LOGE(TAG, "audio_stream_write error %d", error);
|
||||||
self->running_ = false;
|
self->running_ = false;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush silence
|
// Flush silence to avoid pop
|
||||||
memset(self->audioBuffer_, 0, sizeof(self->audioBuffer_));
|
if (self->streamHandle_ != nullptr) {
|
||||||
i2s_controller_write(self->i2sDevice_, self->audioBuffer_, sizeof(self->audioBuffer_), &written, pdMS_TO_TICKS(50));
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Audio task exiting");
|
ESP_LOGI(TAG, "Audio task exiting");
|
||||||
|
|
||||||
@@ -494,33 +502,38 @@ void SfxEngine::audioTaskFunc(void* param) {
|
|||||||
bool SfxEngine::start() {
|
bool SfxEngine::start() {
|
||||||
if (running_) return true;
|
if (running_) return true;
|
||||||
|
|
||||||
// Find I2S device
|
// Find audio-stream device (new audio provision)
|
||||||
i2sDevice_ = nullptr;
|
streamDevice_ = nullptr;
|
||||||
device_for_each_of_type(&I2S_CONTROLLER_TYPE, &i2sDevice_, [](Device* device, void* context) {
|
streamHandle_ = nullptr;
|
||||||
if (!device_is_ready(device)) return true;
|
|
||||||
Device** devicePtr = static_cast<Device**>(context);
|
|
||||||
*devicePtr = device;
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (i2sDevice_ == nullptr) {
|
streamDevice_ = device_find_by_name("audio-stream");
|
||||||
ESP_LOGW(TAG, "No I2S device found");
|
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");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure I2S
|
// Open output stream with resampling (app wants 16k stereo, codec native is 44100)
|
||||||
I2sConfig config = {
|
struct AudioStreamConfig cfg = {
|
||||||
.communication_format = I2S_FORMAT_STAND_I2S,
|
|
||||||
.sample_rate = SAMPLE_RATE,
|
.sample_rate = SAMPLE_RATE,
|
||||||
.bits_per_sample = 16,
|
.bits_per_sample = 16,
|
||||||
.channel_left = 0,
|
.channels = 2
|
||||||
.channel_right = 0
|
|
||||||
};
|
};
|
||||||
|
|
||||||
error_t error = i2s_controller_set_config(i2sDevice_, &config);
|
error_t error = audio_stream_open_output(streamDevice_, &cfg, &streamHandle_);
|
||||||
if (error != ERROR_NONE) {
|
if (error != ERROR_NONE) {
|
||||||
ESP_LOGE(TAG, "Failed to configure I2S: %s", error_to_string(error));
|
ESP_LOGE(TAG, "Failed to open audio-stream: %s (%d)", error_to_string(error), error);
|
||||||
i2sDevice_ = nullptr;
|
streamDevice_ = nullptr;
|
||||||
|
streamHandle_ = nullptr;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,12 +541,13 @@ bool SfxEngine::start() {
|
|||||||
msgQueue_ = xQueueCreate(8, sizeof(QueueMsg));
|
msgQueue_ = xQueueCreate(8, sizeof(QueueMsg));
|
||||||
if (msgQueue_ == nullptr) {
|
if (msgQueue_ == nullptr) {
|
||||||
ESP_LOGE(TAG, "Failed to create message queue");
|
ESP_LOGE(TAG, "Failed to create message queue");
|
||||||
i2s_controller_reset(i2sDevice_);
|
audio_stream_close(streamHandle_);
|
||||||
i2sDevice_ = nullptr;
|
streamHandle_ = nullptr;
|
||||||
|
streamDevice_ = nullptr;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start audio task
|
// Start audio task (needs slightly larger stack for audio_stream write path)
|
||||||
running_ = true;
|
running_ = true;
|
||||||
BaseType_t result = xTaskCreate(audioTaskFunc, "sfxeng", 4096, this, 5, &task_);
|
BaseType_t result = xTaskCreate(audioTaskFunc, "sfxeng", 4096, this, 5, &task_);
|
||||||
if (result != pdPASS) {
|
if (result != pdPASS) {
|
||||||
@@ -541,12 +555,13 @@ bool SfxEngine::start() {
|
|||||||
running_ = false;
|
running_ = false;
|
||||||
vQueueDelete(msgQueue_);
|
vQueueDelete(msgQueue_);
|
||||||
msgQueue_ = nullptr;
|
msgQueue_ = nullptr;
|
||||||
i2s_controller_reset(i2sDevice_);
|
audio_stream_close(streamHandle_);
|
||||||
i2sDevice_ = nullptr;
|
streamHandle_ = nullptr;
|
||||||
|
streamDevice_ = nullptr;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
ESP_LOGI(TAG, "SfxEngine started (voices=%d, sampleRate=%d)", NUM_VOICES, SAMPLE_RATE);
|
ESP_LOGI(TAG, "SfxEngine started via audio-stream (voices=%d, sampleRate=%d -> resampled to native)", NUM_VOICES, SAMPLE_RATE);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -575,11 +590,13 @@ void SfxEngine::stop() {
|
|||||||
msgQueue_ = nullptr;
|
msgQueue_ = nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (i2sDevice_ != nullptr) {
|
if (streamHandle_ != nullptr) {
|
||||||
i2s_controller_reset(i2sDevice_);
|
audio_stream_close(streamHandle_);
|
||||||
i2sDevice_ = nullptr;
|
streamHandle_ = nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
streamDevice_ = nullptr;
|
||||||
|
|
||||||
ESP_LOGI(TAG, "SfxEngine stopped");
|
ESP_LOGI(TAG, "SfxEngine stopped");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+56
-41
@@ -1,14 +1,23 @@
|
|||||||
import json
|
import json
|
||||||
|
import subprocess
|
||||||
import tarfile
|
import tarfile
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
import configparser
|
|
||||||
import sys
|
import sys
|
||||||
|
from datetime import datetime, UTC
|
||||||
|
|
||||||
def read_properties_file(path):
|
def read_properties_file(path):
|
||||||
config = configparser.RawConfigParser()
|
properties = {}
|
||||||
config.read(path)
|
with open(path, "r") as file:
|
||||||
return config
|
for line in file:
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped or stripped.startswith("#"):
|
||||||
|
continue
|
||||||
|
key, sep, value = line.partition("=")
|
||||||
|
if not sep:
|
||||||
|
continue
|
||||||
|
properties[key.strip()] = value.strip()
|
||||||
|
return properties
|
||||||
|
|
||||||
def get_manifest(appPath):
|
def get_manifest(appPath):
|
||||||
"""Extract only the file named 'manifest.properties' from the given tar/tar.gz
|
"""Extract only the file named 'manifest.properties' from the given tar/tar.gz
|
||||||
@@ -62,51 +71,49 @@ def get_manifest(appPath):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def get_versioned_file_name(manifest):
|
def get_versioned_file_name(manifest):
|
||||||
app_id = manifest["app"]["id"]
|
app_id = manifest["app.id"]
|
||||||
version_code = manifest["app"]["versionCode"]
|
version_code = manifest["app.version.code"]
|
||||||
return f"{app_id}-{version_code}.app"
|
return f"{app_id}-{version_code}.app"
|
||||||
|
|
||||||
def get_os_version(manifest):
|
def get_os_version(manifest):
|
||||||
sdk = manifest["target"]["sdk"]
|
sdk = manifest["target.sdk"]
|
||||||
# Remove trailing hyphen suffix if present
|
# Remove trailing hyphen suffix if present
|
||||||
if "-" in sdk:
|
if "-" in sdk:
|
||||||
return sdk.rsplit("-", 1)[0].strip()
|
return sdk.rsplit("-", 1)[0].strip()
|
||||||
else:
|
else:
|
||||||
return sdk
|
return sdk
|
||||||
|
|
||||||
def manifest_config_to_flat_json(manifest):
|
def check_and_get_sdk_version(manifest_map):
|
||||||
"""Convert a ConfigParser manifest into a flat JSON-like dict.
|
"""Ensure all apps target the same (simplified) SDK version and return it."""
|
||||||
|
versions = {get_os_version(manifest) for manifest in manifest_map.values()}
|
||||||
|
if len(versions) != 1:
|
||||||
|
print(f"ERROR: Apps target multiple SDK versions: {sorted(versions)}. All apps must target the same SDK version.")
|
||||||
|
sys.exit(1)
|
||||||
|
return next(iter(versions))
|
||||||
|
|
||||||
Expected sections/keys (case-insensitive for keys):
|
def get_git_commit_hash():
|
||||||
- [app]
|
return subprocess.check_output(['git', 'rev-parse', 'HEAD']).decode('ascii').strip()
|
||||||
id -> appId
|
|
||||||
versionName -> appVersionName
|
def manifest_config_to_flat_json(manifest):
|
||||||
versionCode -> appVersionCode (int)
|
"""Convert a flat (V2) manifest dict into a flat JSON-like dict.
|
||||||
name -> appName
|
|
||||||
description -> appDescription (optional; default "")
|
Expected keys:
|
||||||
- [target]
|
app.id -> appId
|
||||||
sdk -> targetSdk
|
app.version.name -> appVersionName
|
||||||
platforms -> targetPlatforms (comma-separated list)
|
app.version.code -> appVersionCode (int)
|
||||||
|
app.name -> appName
|
||||||
|
app.description -> appDescription (optional; default "")
|
||||||
|
target.sdk -> targetSdk
|
||||||
|
target.platforms -> targetPlatforms (comma-separated list)
|
||||||
|
|
||||||
Unknown/missing values fall back to sensible defaults per requirements.
|
Unknown/missing values fall back to sensible defaults per requirements.
|
||||||
"""
|
"""
|
||||||
def get_opt(section, option, default=None):
|
|
||||||
if not manifest.has_section(section):
|
|
||||||
return default
|
|
||||||
# try exact option then lowercase (RawConfigParser lowercases by default)
|
|
||||||
if manifest.has_option(section, option):
|
|
||||||
return manifest.get(section, option)
|
|
||||||
low = option.lower()
|
|
||||||
if manifest.has_option(section, low):
|
|
||||||
return manifest.get(section, low)
|
|
||||||
return default
|
|
||||||
|
|
||||||
# Map values
|
# Map values
|
||||||
app_id = get_opt("app", "id", "")
|
app_id = manifest.get("app.id", "")
|
||||||
app_version_name = get_opt("app", "versionName", "")
|
app_version_name = manifest.get("app.version.name", "")
|
||||||
app_version_code_raw = get_opt("app", "versionCode", "0")
|
app_version_code_raw = manifest.get("app.version.code", "0")
|
||||||
app_name = get_opt("app", "name", "")
|
app_name = manifest.get("app.name", "")
|
||||||
app_description = get_opt("app", "description", "") or ""
|
app_description = manifest.get("app.description", "") or ""
|
||||||
|
|
||||||
# Coerce version code to int safely
|
# Coerce version code to int safely
|
||||||
try:
|
try:
|
||||||
@@ -114,8 +121,8 @@ def manifest_config_to_flat_json(manifest):
|
|||||||
except Exception:
|
except Exception:
|
||||||
app_version_code = 0
|
app_version_code = 0
|
||||||
|
|
||||||
target_sdk = get_opt("target", "sdk", "")
|
target_sdk = manifest.get("target.sdk", "")
|
||||||
platforms_raw = get_opt("target", "platforms", "")
|
platforms_raw = manifest.get("target.platforms", "")
|
||||||
target_platforms = [p.strip() for p in str(platforms_raw).split(",") if p.strip()] if platforms_raw is not None else []
|
target_platforms = [p.strip() for p in str(platforms_raw).split(",") if p.strip()] if platforms_raw is not None else []
|
||||||
|
|
||||||
filename = get_versioned_file_name(manifest)
|
filename = get_versioned_file_name(manifest)
|
||||||
@@ -142,15 +149,23 @@ if __name__ == "__main__":
|
|||||||
sys.exit()
|
sys.exit()
|
||||||
app_directory = sys.argv[1]
|
app_directory = sys.argv[1]
|
||||||
manifest_map = {}
|
manifest_map = {}
|
||||||
output_json = {
|
|
||||||
"apps": []
|
|
||||||
}
|
|
||||||
any_manifest = None
|
any_manifest = None
|
||||||
if os.path.exists(app_directory):
|
if os.path.exists(app_directory):
|
||||||
for file in os.listdir(app_directory):
|
for file in os.listdir(app_directory):
|
||||||
if file.endswith(".app"):
|
if file.endswith(".app"):
|
||||||
file_path = os.path.join(app_directory, file)
|
file_path = os.path.join(app_directory, file)
|
||||||
manifest_map[file_path] = get_manifest(file_path)
|
manifest_map[file_path] = get_manifest(file_path)
|
||||||
|
# All bundled apps must target the same SDK version; this becomes the CDN path segment
|
||||||
|
sdk_version = check_and_get_sdk_version(manifest_map)
|
||||||
|
with open("sdk_version.txt", "w") as f:
|
||||||
|
f.write(sdk_version)
|
||||||
|
print(f"SDK version: {sdk_version}")
|
||||||
|
output_json = {
|
||||||
|
"sdkVersion": sdk_version,
|
||||||
|
"created": datetime.now(UTC).strftime('%Y-%m-%dT%H:%M:%SZ'),
|
||||||
|
"gitCommit": get_git_commit_hash(),
|
||||||
|
"apps": []
|
||||||
|
}
|
||||||
# Rename files and collect manifest data into output json object
|
# Rename files and collect manifest data into output json object
|
||||||
for file_path in manifest_map.keys():
|
for file_path in manifest_map.keys():
|
||||||
print(f"Processing {file_path}: {manifest_map[file_path]}")
|
print(f"Processing {file_path}: {manifest_map[file_path]}")
|
||||||
@@ -164,4 +179,4 @@ if __name__ == "__main__":
|
|||||||
any_manifest = manifest
|
any_manifest = manifest
|
||||||
# Write JSON
|
# Write JSON
|
||||||
output_json_path = os.path.join(app_directory, "apps.json")
|
output_json_path = os.path.join(app_directory, "apps.json")
|
||||||
write_json(output_json_path, output_json)
|
write_json(output_json_path, output_json)
|
||||||
|
|||||||
+23
-36
@@ -1,4 +1,3 @@
|
|||||||
import configparser
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -13,7 +12,7 @@ import tarfile
|
|||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
ttbuild_path = ".tactility"
|
ttbuild_path = ".tactility"
|
||||||
ttbuild_version = "3.5.1"
|
ttbuild_version = "4.1.0"
|
||||||
ttbuild_cdn = "https://cdn.tactilityproject.org"
|
ttbuild_cdn = "https://cdn.tactilityproject.org"
|
||||||
ttbuild_sdk_json_validity = 3600 # seconds
|
ttbuild_sdk_json_validity = 3600 # seconds
|
||||||
ttport = 6666
|
ttport = 6666
|
||||||
@@ -106,9 +105,17 @@ def get_url(ip, path):
|
|||||||
return f"http://{ip}:{ttport}{path}"
|
return f"http://{ip}:{ttport}{path}"
|
||||||
|
|
||||||
def read_properties_file(path):
|
def read_properties_file(path):
|
||||||
config = configparser.RawConfigParser()
|
properties = {}
|
||||||
config.read(path)
|
with open(path, "r") as file:
|
||||||
return config
|
for line in file:
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped or stripped.startswith("#"):
|
||||||
|
continue
|
||||||
|
key, sep, value = line.partition("=")
|
||||||
|
if not sep:
|
||||||
|
continue
|
||||||
|
properties[key.strip()] = value.strip()
|
||||||
|
return properties
|
||||||
|
|
||||||
#endregion Core
|
#endregion Core
|
||||||
|
|
||||||
@@ -185,7 +192,7 @@ def fetch_sdkconfig_files(platform_targets):
|
|||||||
for platform in platform_targets:
|
for platform in platform_targets:
|
||||||
sdkconfig_filename = f"sdkconfig.app.{platform}"
|
sdkconfig_filename = f"sdkconfig.app.{platform}"
|
||||||
target_path = os.path.join(ttbuild_path, sdkconfig_filename)
|
target_path = os.path.join(ttbuild_path, sdkconfig_filename)
|
||||||
if not download_file(f"{ttbuild_cdn}/{sdkconfig_filename}", target_path):
|
if not download_file(f"{ttbuild_cdn}/sdk/{sdkconfig_filename}", target_path):
|
||||||
exit_with_error(f"Failed to download sdkconfig file for {platform}")
|
exit_with_error(f"Failed to download sdkconfig file for {platform}")
|
||||||
|
|
||||||
#endregion SDK helpers
|
#endregion SDK helpers
|
||||||
@@ -231,32 +238,12 @@ def read_manifest():
|
|||||||
return read_properties_file("manifest.properties")
|
return read_properties_file("manifest.properties")
|
||||||
|
|
||||||
def validate_manifest(manifest):
|
def validate_manifest(manifest):
|
||||||
# [manifest]
|
for key in ("manifest.version", "target.sdk", "target.platforms", "app.id", "app.version.name", "app.version.code", "app.name"):
|
||||||
if not "manifest" in manifest:
|
if key not in manifest:
|
||||||
exit_with_error("Invalid manifest format: [manifest] not found")
|
exit_with_error(f"Invalid manifest format: {key} not found")
|
||||||
if not "version" in manifest["manifest"]:
|
|
||||||
exit_with_error("Invalid manifest format: [manifest] version not found")
|
|
||||||
# [target]
|
|
||||||
if not "target" in manifest:
|
|
||||||
exit_with_error("Invalid manifest format: [target] not found")
|
|
||||||
if not "sdk" in manifest["target"]:
|
|
||||||
exit_with_error("Invalid manifest format: [target] sdk not found")
|
|
||||||
if not "platforms" in manifest["target"]:
|
|
||||||
exit_with_error("Invalid manifest format: [target] platforms not found")
|
|
||||||
# [app]
|
|
||||||
if not "app" in manifest:
|
|
||||||
exit_with_error("Invalid manifest format: [app] not found")
|
|
||||||
if not "id" in manifest["app"]:
|
|
||||||
exit_with_error("Invalid manifest format: [app] id not found")
|
|
||||||
if not "versionName" in manifest["app"]:
|
|
||||||
exit_with_error("Invalid manifest format: [app] versionName not found")
|
|
||||||
if not "versionCode" in manifest["app"]:
|
|
||||||
exit_with_error("Invalid manifest format: [app] versionCode not found")
|
|
||||||
if not "name" in manifest["app"]:
|
|
||||||
exit_with_error("Invalid manifest format: [app] name not found")
|
|
||||||
|
|
||||||
def is_valid_manifest_platform(manifest, platform):
|
def is_valid_manifest_platform(manifest, platform):
|
||||||
manifest_platforms = manifest["target"]["platforms"].split(",")
|
manifest_platforms = manifest["target.platforms"].split(",")
|
||||||
return platform in manifest_platforms
|
return platform in manifest_platforms
|
||||||
|
|
||||||
def validate_manifest_platform(manifest, platform):
|
def validate_manifest_platform(manifest, platform):
|
||||||
@@ -265,7 +252,7 @@ def validate_manifest_platform(manifest, platform):
|
|||||||
|
|
||||||
def get_manifest_target_platforms(manifest, requested_platform):
|
def get_manifest_target_platforms(manifest, requested_platform):
|
||||||
if requested_platform == "" or requested_platform is None:
|
if requested_platform == "" or requested_platform is None:
|
||||||
return manifest["target"]["platforms"].split(",")
|
return manifest["target.platforms"].split(",")
|
||||||
else:
|
else:
|
||||||
validate_manifest_platform(manifest, requested_platform)
|
validate_manifest_platform(manifest, requested_platform)
|
||||||
return [requested_platform]
|
return [requested_platform]
|
||||||
@@ -512,7 +499,7 @@ def build_action(manifest, platform_arg, skip_build):
|
|||||||
if use_local_sdk:
|
if use_local_sdk:
|
||||||
global local_base_path
|
global local_base_path
|
||||||
local_base_path = os.environ.get("TACTILITY_SDK_PATH")
|
local_base_path = os.environ.get("TACTILITY_SDK_PATH")
|
||||||
validate_local_sdks(platforms_to_build, manifest["target"]["sdk"])
|
validate_local_sdks(platforms_to_build, manifest["target.sdk"])
|
||||||
|
|
||||||
if should_fetch_sdkconfig_files(platforms_to_build):
|
if should_fetch_sdkconfig_files(platforms_to_build):
|
||||||
fetch_sdkconfig_files(platforms_to_build)
|
fetch_sdkconfig_files(platforms_to_build)
|
||||||
@@ -521,7 +508,7 @@ def build_action(manifest, platform_arg, skip_build):
|
|||||||
sdk_json = read_sdk_json()
|
sdk_json = read_sdk_json()
|
||||||
validate_self(sdk_json)
|
validate_self(sdk_json)
|
||||||
# Build
|
# Build
|
||||||
sdk_version = manifest["target"]["sdk"]
|
sdk_version = manifest["target.sdk"]
|
||||||
if not use_local_sdk:
|
if not use_local_sdk:
|
||||||
if not sdk_download_all(sdk_version, platforms_to_build):
|
if not sdk_download_all(sdk_version, platforms_to_build):
|
||||||
exit_with_error("Failed to download one or more SDKs")
|
exit_with_error("Failed to download one or more SDKs")
|
||||||
@@ -570,7 +557,7 @@ def get_device_info(ip):
|
|||||||
print_status_error(f"Device info request failed: {e}")
|
print_status_error(f"Device info request failed: {e}")
|
||||||
|
|
||||||
def run_action(manifest, ip):
|
def run_action(manifest, ip):
|
||||||
app_id = manifest["app"]["id"]
|
app_id = manifest["app.id"]
|
||||||
print_status_busy("Running")
|
print_status_busy("Running")
|
||||||
url = get_url(ip, "/app/run")
|
url = get_url(ip, "/app/run")
|
||||||
params = {'id': app_id}
|
params = {'id': app_id}
|
||||||
@@ -614,7 +601,7 @@ def install_action(ip, platforms):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def uninstall_action(manifest, ip):
|
def uninstall_action(manifest, ip):
|
||||||
app_id = manifest["app"]["id"]
|
app_id = manifest["app.id"]
|
||||||
print_status_busy("Uninstalling")
|
print_status_busy("Uninstalling")
|
||||||
url = get_url(ip, "/app/uninstall")
|
url = get_url(ip, "/app/uninstall")
|
||||||
params = {'id': app_id}
|
params = {'id': app_id}
|
||||||
@@ -670,7 +657,7 @@ if __name__ == "__main__":
|
|||||||
exit_with_error("manifest.properties not found")
|
exit_with_error("manifest.properties not found")
|
||||||
manifest = read_manifest()
|
manifest = read_manifest()
|
||||||
validate_manifest(manifest)
|
validate_manifest(manifest)
|
||||||
all_platform_targets = manifest["target"]["platforms"].split(",")
|
all_platform_targets = manifest["target.platforms"].split(",")
|
||||||
# Update SDK cache (tool.json)
|
# Update SDK cache (tool.json)
|
||||||
if not use_local_sdk and should_update_tool_json() and not update_tool_json():
|
if not use_local_sdk and should_update_tool_json() and not update_tool_json():
|
||||||
exit_with_error("Failed to retrieve SDK info")
|
exit_with_error("Failed to retrieve SDK info")
|
||||||
|
|||||||
Reference in New Issue
Block a user