Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be9229b80d | |||
| 6bfe514ff1 | |||
| be07675d00 | |||
| 343da892bc | |||
| 80ab2880d9 |
@@ -1,16 +1,12 @@
|
|||||||
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 environment variable is not set, defaulting to ${TACTILITY_SDK_PATH}")
|
message(WARNING "TACTILITY_SDK_PATH 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)
|
||||||
project(ImmichElias)
|
tactility_project(DiscoveryMountain)
|
||||||
tactility_project(ImmichElias)
|
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
# DiscoveryMountain – Crash After Install – Root Cause & Fix
|
||||||
|
|
||||||
|
**Board:** CYD ES3C28P (es3c28p) `192.168.68.132`
|
||||||
|
**Firmware:** 0.8.0-dev / IDF 5.5.2
|
||||||
|
**App ID:** `one.tactility.discoverymountain`
|
||||||
|
**Branch:** `personal/discovery-mountain-player` → merged into `main`
|
||||||
|
**Date:** 2026-07-21
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- After `tactility.py install 192.168.68.132`, device reboots or shows blue dialog `Application failed to start: missing symbol`
|
||||||
|
- Serial logs: `E ELF: Can't find symbol ...`, then `Guru Meditation` (LoadProhibited, StoreProhibited, IllegalInstruction, Cache error)
|
||||||
|
- Even when it did start, fetch fell back to 6 dummy episodes (`S01E01`…) with empty `audio_url`, so download always failed
|
||||||
|
- When forced to download 4.3 MB MP3 from `http://192.168.68.110:8098/.../esp32_24k.mp3`, device hard-faulted during `play_task`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Missing Symbol Loader Failures
|
||||||
|
|
||||||
|
### Detected via `xtensa-esp32s3-elf-nm -D …elf | grep " U "`
|
||||||
|
|
||||||
|
```
|
||||||
|
U lv_font_montserrat_14
|
||||||
|
U lv_roller_create
|
||||||
|
U lv_roller_set_options
|
||||||
|
U lv_roller_get_selected
|
||||||
|
U lv_roller_set_selected
|
||||||
|
U qsort
|
||||||
|
U cJSON_* (via json component)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:**
|
||||||
|
- Firmware exports defined in `firmware/Modules/lvgl-module/source/symbols.c` – roller not exported
|
||||||
|
- `lv_font_montserrat_14` is a data symbol, not exported (AGENTS.md known missing)
|
||||||
|
- `qsort` not in `g_esp_libc_elfsyms` nor `main_symbols`
|
||||||
|
- `cJSON` not exported – apps must vendor `cJSON.c` (see `BookPlayer`, `McpScreen`)
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- `manifest.properties` V1 INI → V2 flat:
|
||||||
|
```
|
||||||
|
manifest.version=0.2
|
||||||
|
target.sdk=0.8.0-dev
|
||||||
|
target.platforms=esp32s3
|
||||||
|
app.id=one.tactility.discoverymountain
|
||||||
|
app.version.name=0.1.0
|
||||||
|
app.version.code=1
|
||||||
|
```
|
||||||
|
- `main/CMakeLists.txt`:
|
||||||
|
```cmake
|
||||||
|
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 lwip)
|
||||||
|
```
|
||||||
|
- Replace roller with `lv_dropdown` (exported, see `symbols.c:272-291`)
|
||||||
|
- Replace `qsort` with bubble sort
|
||||||
|
- Replace `lv_font_montserrat_14` with `lvgl_get_text_font(FONT_SIZE_DEFAULT)` from `tactility/lvgl_fonts.h`
|
||||||
|
|
||||||
|
After fix: `nm -D` shows no missing roller/qsort/font, verify script passes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Fatal `memset` Bug
|
||||||
|
|
||||||
|
```c
|
||||||
|
memset(&G,0,0); // size 0!
|
||||||
|
```
|
||||||
|
Static `G` is zero-initialized first boot, but second `onShow` leaves dangling `lv_obj_t*`, `play_handle`, etc.
|
||||||
|
|
||||||
|
**Fix:** `memset(&G,0,sizeof(G))`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Blocking LVGL Thread → Watchdog Reboot
|
||||||
|
|
||||||
|
Original `onShow`:
|
||||||
|
```c
|
||||||
|
load_state(); fetch_data(); build_ui();
|
||||||
|
```
|
||||||
|
`fetch_data()` did 2× `esp_http_client_perform` with 8s timeout + `cJSON_Parse` of 91KB JSON on LVGL thread → 16s block → TWDT.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- Build UI immediately with "Fetching..."
|
||||||
|
- `xTaskCreate(fetch_task_fn,"dm_fetch",16384, ...)`
|
||||||
|
- `fetch_task_fn` does `fetch_data()` in background, then `tt_lvgl_lock` to update labels
|
||||||
|
- Same for download: `dl_task` 12288 stack
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. `esp_http_client` vs Raw Sockets
|
||||||
|
|
||||||
|
- `esp_http_client` failed on device for PB `http://192.168.68.110:8095` (returned chunked `1634\r\n{...}`), while `curl --http1.0` returned plain JSON.
|
||||||
|
|
||||||
|
Raw socket version via `lwip_socket`, `ipaddr_addr`, `my_htons` (like `RobotArm`) is more reliable and allows PSRAM buffers.
|
||||||
|
|
||||||
|
PocketBase:
|
||||||
|
- `HTTP/1.1` → `Transfer-Encoding: chunked` → `1634\r\n{...}\r\n0\r\n\r\n`
|
||||||
|
- `HTTP/1.0` → `Content-Length` absent, body until close → 5684 bytes seasons, 91293 bytes episodes
|
||||||
|
|
||||||
|
**Fix:** Implement `http_get_raw` with:
|
||||||
|
- PSRAM `heap_caps_malloc(65536, MALLOC_CAP_SPIRAM|8BIT)` growing to 600KB
|
||||||
|
- Manual realloc via malloc+memcpy+free (avoid `heap_caps_realloc` not exported)
|
||||||
|
- `strstr(buf,"\r\n\r\n")` to find header end
|
||||||
|
- Detect `chunked`, decode hex chunk sizes via `strtol(hex, NULL, 16)`
|
||||||
|
- Return de-chunked body
|
||||||
|
|
||||||
|
Result: `DM: seasons items count 37`, `episodes items 232` (was fallback 37/6)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Download Buffer & PSRAM
|
||||||
|
|
||||||
|
- `dl_ep` used `esp_http_client` + `FILE*` with 2KB stack buf, but also `rename` with static `sd_path()` race
|
||||||
|
- 4.3MB MP3 download needs streaming, not full RAM
|
||||||
|
- `malloc(600000)` in internal heap (120KB free) → OOM → cache error
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- `make_sd_path(out,len,slug)` caller-provided buffer
|
||||||
|
- `ensure_dir()` does NOT `mkdir("/sdcard")`
|
||||||
|
- `dl_ep_raw` uses PSRAM `hdr 8192` + `recv_buf 4096`, streams directly to `FILE*`
|
||||||
|
- Progress via `lv_label_set_text` under `tt_lvgl_lock`
|
||||||
|
- LRU keeps 8 files, skips current playing
|
||||||
|
|
||||||
|
Result: `DL done total 4329701 expected 4329701` – file correctly saved to `/sdcard/dm/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Audio: I2S Direct vs audio-stream + PSRAM
|
||||||
|
|
||||||
|
Original used `i2s_controller_*` directly:
|
||||||
|
```c
|
||||||
|
device_lock(i2s_dev); i2s_controller_set_config(...); i2s_controller_write(...);
|
||||||
|
```
|
||||||
|
Conflicts with Audio service, no resampling, `vTaskDelete` while holding lock → deadlock, `samples*2` overflow for mono.
|
||||||
|
|
||||||
|
**Fix (from BookPlayer):**
|
||||||
|
- `find_audio_device()` via `device_find_by_name("audio-stream")`
|
||||||
|
- `audio_stream_open_output(dev, cfg, &handle)`
|
||||||
|
- `audio_stream_write(handle, pcm+off, ...)`
|
||||||
|
- `close_stream()` via `audio_stream_close`
|
||||||
|
- `wait_play_exit()` uses `tt_lvgl_unlock(); vTaskDelay(10); tt_lvgl_lock()` pattern
|
||||||
|
- Buffers: `inbuf 16384` + `pcm 1152*2*2` from PSRAM
|
||||||
|
- Volume scaling with proper clipping
|
||||||
|
- Stack 12288 for play/dl/fetch (was 6144/8192 → overflow on cJSON 91KB)
|
||||||
|
- Rate-limit `ui_timer` 500ms, only update bar if `pct != last_pct`
|
||||||
|
|
||||||
|
**Playback crash – stack overflow:**
|
||||||
|
```
|
||||||
|
***ERROR*** A stack overflow in task dm_play has been detected.
|
||||||
|
...
|
||||||
|
Guru Meditation: BREAK instr at vTaskGenericNotifyGiveFromISR
|
||||||
|
...
|
||||||
|
PC : 0x40385c6e : vPortYieldFromInt
|
||||||
|
```
|
||||||
|
Root: `mp3dec_t` (~2KB) + large locals on 8192 stack + `malloc` for buffers + `fread` + `memmove` caused overflow.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- Increase `dm_play` stack 8192 → 16384
|
||||||
|
- Allocate `mp3dec_t* dec` from PSRAM via `heap_caps_malloc(..., SPIRAM|8BIT)` instead of stack
|
||||||
|
- Allocate `inbuf` / `pcm` via `malloc` (internal) first, fallback PSRAM
|
||||||
|
- Add ID3v2 skip:
|
||||||
|
```c
|
||||||
|
uint8_t id3hdr[10];
|
||||||
|
if(fread(id3hdr,1,10,file)==10 && memcmp(id3hdr,"ID3",3)==0){
|
||||||
|
int sz = (id3hdr[6]&0x7F)<<21 | ...;
|
||||||
|
fseek(file, sz+10, SEEK_SET);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Add logs `open_stream sr=%d ch=%d`, `open_stream ok`, `decoding frame buffered=%d`
|
||||||
|
|
||||||
|
Result after fix:
|
||||||
|
```
|
||||||
|
DM: play task start /sdcard/dm/463_...mp3 size 4329701
|
||||||
|
DM: open_stream sr=16000 ch=1
|
||||||
|
esp32_i2s: Configuring I2S pins...
|
||||||
|
Adev_Codec: Open codec device OK
|
||||||
|
DM: open_stream ok
|
||||||
|
```
|
||||||
|
Stays alive 50s+ (previously crashed <1s).
|
||||||
|
|
||||||
|
### 7. UI Progress Not Showing (user report: "It is playing now, the UI does not show the progress, maybe due to the autoplay.")
|
||||||
|
|
||||||
|
Root:
|
||||||
|
```c
|
||||||
|
G.pos_sec += samples / info.hz; // integer division 1152/16000 = 0!
|
||||||
|
```
|
||||||
|
`pos_sec` never increments, so `pct = pos_sec*100/total_sec` stays 0, bar never moves. Also `last_pct` not reset on new episode.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- Track `total_decoded_samples += samples; G.pos_sec = total_decoded_samples / hz;`
|
||||||
|
- Reset `last_pct=-1` in `select_ep` and `start_idx_internal`
|
||||||
|
- Keep `ui_timer` 500ms but always update time label, only bar when pct changes
|
||||||
|
- Remove auto-play for final stable (user presses Play manually) – auto-play from `fetch_task` caused race with LVGL lock and hid progress issue
|
||||||
|
|
||||||
|
After fix, progress bar moves and time label updates `0:01 / 4:30` etc.
|
||||||
|
No immediate crash, stays alive 50s+ (previously crashed <1s).
|
||||||
|
|
||||||
|
Remaining TODO: test actual audio output via speaker, try 44.1k file if 16k resampling still issues, add `taskYIELD()` and volume ramp to avoid pop.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build & Deploy (correct env)
|
||||||
|
|
||||||
|
Per `AGENTS.md`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/adolforeyna/Projects/Tactility/apps
|
||||||
|
unset PYTHONPATH; unset PYTHONHOME
|
||||||
|
export IDF_PYTHON_ENV_PATH=/Users/adolforeyna/.espressif/python_env/idf5.5_py3.10_env
|
||||||
|
source /Users/adolforeyna/esp/esp-idf-v5.5.2/export.sh
|
||||||
|
export TACTILITY_SDK_PATH=/Users/adolforeyna/Projects/Tactility/firmware/release/TactilitySDK
|
||||||
|
|
||||||
|
$IDF_PYTHON_ENV_PATH/bin/python tactility.py Apps/DiscoveryMountain build esp32s3 --local-sdk
|
||||||
|
curl -X PUT http://192.168.68.132/api/apps/install -F "file=@Apps/DiscoveryMountain/build/DiscoveryMountain.app"
|
||||||
|
curl -X POST "http://192.168.68.132/api/apps/run?id=one.tactility.discoverymountain"
|
||||||
|
# serial
|
||||||
|
python3 -c "import serial; s=serial.Serial('/dev/cu.usbmodem101',115200); ..."
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify symbols:
|
||||||
|
```bash
|
||||||
|
xtensa-esp32s3-elf-nm -D Apps/DiscoveryMountain/build/cmake-build-esp32s3/DiscoveryMountain.app.elf | grep " U " | grep -E "roller|qsort|montserrat"
|
||||||
|
# should be empty
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final State (2026-07-21 post-fix)
|
||||||
|
|
||||||
|
- **Stable install:** No `missing symbol`, no `memset` garbage, no watchdog. Boots to launcher, shows UI, stays alive.
|
||||||
|
- **Fetch:** `37 seasons / 232 episodes` via raw LWIP sockets + PSRAM 200KB buffer, `HTTP/1.0` to avoid chunked. Previously fallback 6 dummy.
|
||||||
|
- **Download:** `DL done total 4329701 expected 4329701` via simple streaming `lwip_recv` → `FILE*` with PSRAM hdr/recv buffers, LRU keeps 8 files. Previously OOM and crash.
|
||||||
|
- **Playback:** Fixed stack overflow in `dm_play` (8192 → 16384) + `mp3dec_t` heap allocated + ID3 skip. Now:
|
||||||
|
```
|
||||||
|
DM: play task start ... size 4329701
|
||||||
|
DM: open_stream sr=16000 ch=1
|
||||||
|
DM: open_stream ok
|
||||||
|
```
|
||||||
|
No immediate crash, stays alive 50s+. Audio output via `audio_stream` (ES8311) should work, but needs manual play test (auto-play removed for stability).
|
||||||
|
- **UI:** Roller → dropdown, font → `lvgl_get_text_font`, no `HideStatusBar` per user request.
|
||||||
|
- **Build:** Verified `nm -D` clean, `tactility.py build esp32s3 --local-sdk` ok.
|
||||||
|
|
||||||
|
## Verified Logs (final)
|
||||||
|
|
||||||
|
```
|
||||||
|
DM: fetch task start
|
||||||
|
DM: GET .../dm_seasons... → GET ok 5684
|
||||||
|
DM: seasons items count 37
|
||||||
|
DM: GET .../dm_episodes... → GET ok 91293
|
||||||
|
DM: episodes items 232
|
||||||
|
DM: fetch done seasons=37 eps=232
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
|
||||||
|
- [ ] Fix playback: skip ID3v2 before decode, try internal RAM for `mp3dec_t`, test with 44.1k mp3 from other episode (not `esp32_24k`)
|
||||||
|
- [ ] Use `device_get_by_name` / `device_put` instead of deprecated `find`
|
||||||
|
- [ ] Use `tt_app_get_user_data_path` for state file, not hardcoded `/sdcard/apps/...`
|
||||||
|
- [ ] Rate-limit screenshot API (2s gap) to avoid heap spike
|
||||||
@@ -4,6 +4,5 @@ set(CJSON_SOURCE "$ENV{IDF_PATH}/components/json/cJSON/cJSON.c")
|
|||||||
idf_component_register(
|
idf_component_register(
|
||||||
SRCS ${SOURCE_FILES} ${CJSON_SOURCE}
|
SRCS ${SOURCE_FILES} ${CJSON_SOURCE}
|
||||||
INCLUDE_DIRS Source "$ENV{IDF_PATH}/components/json/cJSON"
|
INCLUDE_DIRS Source "$ENV{IDF_PATH}/components/json/cJSON"
|
||||||
REQUIRES TactilitySDK esp_http_client
|
REQUIRES TactilitySDK esp_http_client lwip
|
||||||
)
|
)
|
||||||
target_compile_options(${COMPONENT_LIB} PRIVATE -Wno-format-truncation -Wno-unused-but-set-variable -Wno-unused-function -Wno-unused-variable)
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
#include "app_context.h"
|
||||||
|
|
||||||
|
AppCtx G = {0};
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <tt_lvgl.h>
|
||||||
|
#include <tt_lvgl_toolbar.h>
|
||||||
|
#include <lvgl.h>
|
||||||
|
#include <tactility/lvgl_fonts.h>
|
||||||
|
#include <tactility/device.h>
|
||||||
|
#include <tactility/drivers/audio_stream.h>
|
||||||
|
#include "freertos/FreeRTOS.h"
|
||||||
|
#include "freertos/task.h"
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#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;
|
||||||
|
|
||||||
|
typedef struct { char slug[96]; int pos; } SavedPos;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
SeasonInfo seasons[MAX_SEASONS];
|
||||||
|
int season_cnt;
|
||||||
|
EpInfo eps[MAX_EPS];
|
||||||
|
int ep_cnt;
|
||||||
|
int cur_season;
|
||||||
|
int cur_ep_idx;
|
||||||
|
struct Device* stream_dev;
|
||||||
|
AudioStreamHandle stream_handle;
|
||||||
|
bool is_playing;
|
||||||
|
bool is_paused;
|
||||||
|
bool need_stop;
|
||||||
|
TaskHandle_t play_handle;
|
||||||
|
TaskHandle_t fetch_handle;
|
||||||
|
TaskHandle_t dl_handle;
|
||||||
|
int pos_sec;
|
||||||
|
int total_sec;
|
||||||
|
int volume;
|
||||||
|
int last_pct;
|
||||||
|
lv_obj_t *lbl_title, *lbl_meta, *bar, *lbl_time, *lbl_status, *btn_play;
|
||||||
|
lv_obj_t *overlay, *dropdown;
|
||||||
|
// Download UI
|
||||||
|
lv_obj_t *dl_overlay;
|
||||||
|
lv_obj_t *dl_bar;
|
||||||
|
lv_obj_t *dl_lbl_pct;
|
||||||
|
lv_obj_t *dl_lbl_detail;
|
||||||
|
lv_obj_t *dl_lbl_title;
|
||||||
|
lv_obj_t *dl_lbl_sub;
|
||||||
|
char cur_title[128];
|
||||||
|
char last_slug[96];
|
||||||
|
bool fetching;
|
||||||
|
bool downloading;
|
||||||
|
int dl_total;
|
||||||
|
int dl_expected;
|
||||||
|
int dl_last_ui_update;
|
||||||
|
bool dl_cancel_req;
|
||||||
|
char dl_ep_title[128];
|
||||||
|
char dl_slug[96];
|
||||||
|
int dl_season;
|
||||||
|
int dl_ep_num;
|
||||||
|
int dl_last_pct;
|
||||||
|
// Per-episode resume
|
||||||
|
SavedPos saved_pos[MAX_EPS];
|
||||||
|
int saved_pos_cnt;
|
||||||
|
int last_save_tick;
|
||||||
|
} AppCtx;
|
||||||
|
|
||||||
|
extern AppCtx G;
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
#include "download.h"
|
||||||
|
#include "storage.h"
|
||||||
|
#include "network.h"
|
||||||
|
#include "player.h"
|
||||||
|
#include <tt_lvgl.h>
|
||||||
|
#include <tactility/lvgl_fonts.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <lwip/sockets.h>
|
||||||
|
#include <lwip/inet.h>
|
||||||
|
#include "freertos/FreeRTOS.h"
|
||||||
|
#include "freertos/task.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <errno.h>
|
||||||
|
|
||||||
|
static void dl_cancel_cb(lv_event_t* e){
|
||||||
|
(void)e;
|
||||||
|
ESP_LOGI(TAG,"DL cancel requested");
|
||||||
|
G.dl_cancel_req=true;
|
||||||
|
if(G.dl_lbl_detail) lv_label_set_text(G.dl_lbl_detail,"Canceling...");
|
||||||
|
}
|
||||||
|
|
||||||
|
void hide_dl_overlay(){
|
||||||
|
if(G.dl_overlay){
|
||||||
|
lv_obj_delete(G.dl_overlay);
|
||||||
|
G.dl_overlay=NULL;
|
||||||
|
G.dl_bar=NULL;
|
||||||
|
G.dl_lbl_pct=NULL;
|
||||||
|
G.dl_lbl_detail=NULL;
|
||||||
|
G.dl_lbl_title=NULL;
|
||||||
|
G.dl_lbl_sub=NULL;
|
||||||
|
}
|
||||||
|
G.downloading=false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void show_dl_overlay_locked(const char* ep_title, const char* slug, int season, int ep_num){
|
||||||
|
if(G.dl_overlay) hide_dl_overlay();
|
||||||
|
|
||||||
|
strncpy(G.dl_ep_title, ep_title ? ep_title : "Episode", sizeof(G.dl_ep_title)-1);
|
||||||
|
strncpy(G.dl_slug, slug ? slug : "", sizeof(G.dl_slug)-1);
|
||||||
|
G.dl_season=season;
|
||||||
|
G.dl_ep_num=ep_num;
|
||||||
|
G.dl_total=0;
|
||||||
|
G.dl_expected=-1;
|
||||||
|
G.dl_last_pct=-1;
|
||||||
|
G.dl_cancel_req=false;
|
||||||
|
G.downloading=true;
|
||||||
|
|
||||||
|
G.dl_overlay = lv_obj_create(lv_scr_act());
|
||||||
|
lv_obj_set_size(G.dl_overlay, LV_PCT(100), LV_PCT(100));
|
||||||
|
lv_obj_set_style_bg_color(G.dl_overlay, lv_color_hex(0x000000), 0);
|
||||||
|
lv_obj_set_style_bg_opa(G.dl_overlay, LV_OPA_70, 0);
|
||||||
|
lv_obj_set_style_border_width(G.dl_overlay, 0, 0);
|
||||||
|
lv_obj_set_style_pad_all(G.dl_overlay, 0, 0);
|
||||||
|
lv_obj_set_style_radius(G.dl_overlay, 0, 0);
|
||||||
|
lv_obj_remove_flag(G.dl_overlay, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
|
lv_obj_t* card = lv_obj_create(G.dl_overlay);
|
||||||
|
lv_obj_set_size(card, 220, 210);
|
||||||
|
lv_obj_center(card);
|
||||||
|
lv_obj_set_style_bg_color(card, lv_color_hex(0x1E1E2E), 0);
|
||||||
|
lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0);
|
||||||
|
lv_obj_set_style_border_color(card, lv_color_hex(0x313244), 0);
|
||||||
|
lv_obj_set_style_border_width(card, 1, 0);
|
||||||
|
lv_obj_set_style_radius(card, 12, 0);
|
||||||
|
lv_obj_set_style_pad_all(card, 12, 0);
|
||||||
|
lv_obj_set_style_pad_row(card, 6, 0);
|
||||||
|
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
|
||||||
|
lv_obj_set_flex_align(card, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||||
|
lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
|
lv_obj_t* lbl_head = lv_label_create(card);
|
||||||
|
lv_label_set_text(lbl_head, "Downloading");
|
||||||
|
lv_obj_set_style_text_color(lbl_head, lv_color_hex(0xCDD6F4), 0);
|
||||||
|
lv_obj_set_style_text_font(lbl_head, lvgl_get_text_font(FONT_SIZE_DEFAULT), 0);
|
||||||
|
|
||||||
|
G.dl_lbl_title = lv_label_create(card);
|
||||||
|
lv_label_set_text(G.dl_lbl_title, G.dl_ep_title);
|
||||||
|
lv_obj_set_width(G.dl_lbl_title, 190);
|
||||||
|
lv_obj_set_style_text_color(G.dl_lbl_title, lv_color_hex(0xFFFFFF), 0);
|
||||||
|
lv_obj_set_style_text_align(G.dl_lbl_title, LV_TEXT_ALIGN_CENTER, 0);
|
||||||
|
lv_label_set_long_mode(G.dl_lbl_title, LV_LABEL_LONG_WRAP);
|
||||||
|
|
||||||
|
G.dl_lbl_sub = lv_label_create(card);
|
||||||
|
char sub[64];
|
||||||
|
snprintf(sub, sizeof(sub), "S%02d E%02d • %s", season, ep_num, slug);
|
||||||
|
lv_label_set_text(G.dl_lbl_sub, sub);
|
||||||
|
lv_obj_set_style_text_color(G.dl_lbl_sub, lv_color_hex(0xA6ADC8), 0);
|
||||||
|
lv_obj_set_style_text_font(G.dl_lbl_sub, lvgl_get_text_font(FONT_SIZE_SMALL), 0);
|
||||||
|
lv_obj_set_style_text_align(G.dl_lbl_sub, LV_TEXT_ALIGN_CENTER, 0);
|
||||||
|
|
||||||
|
G.dl_bar = lv_bar_create(card);
|
||||||
|
lv_obj_set_size(G.dl_bar, 190, 14);
|
||||||
|
lv_bar_set_range(G.dl_bar, 0, 100);
|
||||||
|
lv_bar_set_value(G.dl_bar, 0, LV_ANIM_OFF);
|
||||||
|
lv_obj_set_style_bg_color(G.dl_bar, lv_color_hex(0x45475A), LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_bg_color(G.dl_bar, lv_color_hex(0x89B4FA), LV_PART_INDICATOR);
|
||||||
|
lv_obj_set_style_radius(G.dl_bar, 7, 0);
|
||||||
|
|
||||||
|
G.dl_lbl_pct = lv_label_create(card);
|
||||||
|
lv_label_set_text(G.dl_lbl_pct, "0%");
|
||||||
|
lv_obj_set_style_text_color(G.dl_lbl_pct, lv_color_hex(0xCDD6F4), 0);
|
||||||
|
lv_obj_set_style_text_font(G.dl_lbl_pct, lvgl_get_text_font(FONT_SIZE_DEFAULT), 0);
|
||||||
|
|
||||||
|
G.dl_lbl_detail = lv_label_create(card);
|
||||||
|
lv_label_set_text(G.dl_lbl_detail, "Starting…");
|
||||||
|
lv_obj_set_style_text_color(G.dl_lbl_detail, lv_color_hex(0xA6ADC8), 0);
|
||||||
|
lv_obj_set_style_text_font(G.dl_lbl_detail, lvgl_get_text_font(FONT_SIZE_SMALL), 0);
|
||||||
|
lv_obj_set_style_text_align(G.dl_lbl_detail, LV_TEXT_ALIGN_CENTER, 0);
|
||||||
|
lv_obj_set_width(G.dl_lbl_detail, 190);
|
||||||
|
lv_label_set_long_mode(G.dl_lbl_detail, LV_LABEL_LONG_WRAP);
|
||||||
|
|
||||||
|
lv_obj_t* btn_cancel = lv_button_create(card);
|
||||||
|
lv_obj_set_size(btn_cancel, 90, 32);
|
||||||
|
lv_obj_set_style_radius(btn_cancel, 8, 0);
|
||||||
|
lv_obj_set_style_bg_color(btn_cancel, lv_color_hex(0x313244), 0);
|
||||||
|
lv_obj_t* lbl_c = lv_label_create(btn_cancel);
|
||||||
|
lv_label_set_text(lbl_c, "Cancel");
|
||||||
|
lv_obj_center(lbl_c);
|
||||||
|
lv_obj_add_event_cb(btn_cancel, dl_cancel_cb, LV_EVENT_CLICKED, NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
void update_dl_overlay_ui(){
|
||||||
|
if(!G.downloading || !G.dl_overlay) return;
|
||||||
|
if(G.dl_bar){
|
||||||
|
int pct=0;
|
||||||
|
if(G.dl_expected>0){
|
||||||
|
pct = (int)((int64_t)G.dl_total * 100 / G.dl_expected);
|
||||||
|
if(pct<0) pct=0;
|
||||||
|
if(pct>100) pct=100;
|
||||||
|
} else {
|
||||||
|
pct = G.dl_last_pct;
|
||||||
|
if(pct<0) pct=0;
|
||||||
|
}
|
||||||
|
if(pct!=G.dl_last_pct){
|
||||||
|
G.dl_last_pct=pct;
|
||||||
|
lv_bar_set_value(G.dl_bar, pct, LV_ANIM_OFF);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(G.dl_lbl_pct){
|
||||||
|
char b[16];
|
||||||
|
if(G.dl_expected>0){
|
||||||
|
int pct = (int)((int64_t)G.dl_total * 100 / G.dl_expected);
|
||||||
|
if(pct<0) pct=0;
|
||||||
|
if(pct>100) pct=100;
|
||||||
|
snprintf(b,sizeof(b),"%d%%",pct);
|
||||||
|
} else {
|
||||||
|
snprintf(b,sizeof(b),"%d KB",G.dl_total/1024);
|
||||||
|
}
|
||||||
|
lv_label_set_text(G.dl_lbl_pct,b);
|
||||||
|
}
|
||||||
|
if(G.dl_lbl_detail){
|
||||||
|
char d[64];
|
||||||
|
if(G.dl_expected>0){
|
||||||
|
int total_kb = G.dl_total/1024;
|
||||||
|
int exp_kb = G.dl_expected/1024;
|
||||||
|
if(exp_kb>1024){
|
||||||
|
snprintf(d,sizeof(d),"%d KB / %d KB (%d.%01d MB)", total_kb, exp_kb, exp_kb/1024, (exp_kb%1024)*10/1024);
|
||||||
|
} else {
|
||||||
|
snprintf(d,sizeof(d),"%d / %d KB", total_kb, exp_kb);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
snprintf(d,sizeof(d),"%d KB downloaded", G.dl_total/1024);
|
||||||
|
}
|
||||||
|
lv_label_set_text(G.dl_lbl_detail,d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool dl_ep_raw(EpInfo* ep){
|
||||||
|
if(!ep) return false;
|
||||||
|
if(strlen(ep->audio_url)==0){ ESP_LOGE(TAG,"dl no url for %s",ep->slug); return false; }
|
||||||
|
|
||||||
|
char url_norm[512];
|
||||||
|
normalize_audio_url(ep->audio_url, url_norm, sizeof(url_norm));
|
||||||
|
ESP_LOGI(TAG,"dl url normalized: %s -> %s", ep->audio_url, url_norm);
|
||||||
|
|
||||||
|
const char* url=url_norm;
|
||||||
|
if(strncmp(url,"http://",7)!=0){
|
||||||
|
ESP_LOGE(TAG,"dl url not http %s",url);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const char* p=url+7;
|
||||||
|
const char* slash=strchr(p,'/');
|
||||||
|
if(!slash){ ESP_LOGE(TAG,"dl no slash"); return false; }
|
||||||
|
char hostport[128]={0};
|
||||||
|
size_t hlen=slash-p;
|
||||||
|
if(hlen>=sizeof(hostport)){ ESP_LOGE(TAG,"dl hostport too long"); return false; }
|
||||||
|
memcpy(hostport,p,hlen);
|
||||||
|
hostport[hlen]=0;
|
||||||
|
const char* path=slash;
|
||||||
|
char host[96]={0};
|
||||||
|
int port=80;
|
||||||
|
char* colon=strchr(hostport,':');
|
||||||
|
if(colon){
|
||||||
|
*colon=0;
|
||||||
|
strncpy(host,hostport,sizeof(host)-1);
|
||||||
|
port=atoi(colon+1);
|
||||||
|
if(port<=0) port=80;
|
||||||
|
} else {
|
||||||
|
strncpy(host,hostport,sizeof(host)-1);
|
||||||
|
}
|
||||||
|
uint32_t ip=0;
|
||||||
|
if(!resolve_host(host,&ip)){ ESP_LOGE(TAG,"dl ip fail %s",host); return false; }
|
||||||
|
|
||||||
|
char final_path[300];
|
||||||
|
char tmp_path[320];
|
||||||
|
make_sd_path(final_path,sizeof(final_path),ep->slug);
|
||||||
|
snprintf(tmp_path,sizeof(tmp_path),"%s.tmp",final_path);
|
||||||
|
unlink(tmp_path);
|
||||||
|
FILE* f=fopen(tmp_path,"wb");
|
||||||
|
if(!f){ ESP_LOGE(TAG,"dl fopen fail %s",tmp_path); return false; }
|
||||||
|
|
||||||
|
int fd=lwip_socket(AF_INET,SOCK_STREAM,0);
|
||||||
|
if(fd<0){ ESP_LOGE(TAG,"dl socket fail"); fclose(f); unlink(tmp_path); return false; }
|
||||||
|
struct sockaddr_in sa;
|
||||||
|
memset(&sa,0,sizeof(sa));
|
||||||
|
sa.sin_family=AF_INET;
|
||||||
|
sa.sin_port=my_htons(port);
|
||||||
|
sa.sin_addr.s_addr=ip;
|
||||||
|
struct timeval tv={15,0};
|
||||||
|
lwip_setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&tv,sizeof(tv));
|
||||||
|
lwip_setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&tv,sizeof(tv));
|
||||||
|
ESP_LOGI(TAG,"dl connecting %s:%d",host,port);
|
||||||
|
if(lwip_connect(fd,(struct sockaddr*)&sa,sizeof(sa))<0){
|
||||||
|
ESP_LOGE(TAG,"dl connect fail %s:%d errno=%d",host,port,errno);
|
||||||
|
fclose(f); close(fd); unlink(tmp_path); return false;
|
||||||
|
}
|
||||||
|
ESP_LOGI(TAG,"dl connected");
|
||||||
|
|
||||||
|
char req[512];
|
||||||
|
int reqlen=snprintf(req,sizeof(req),"GET %s HTTP/1.0\r\nHost: %s:%d\r\nConnection: close\r\nUser-Agent: TactilityDM/0.1\r\nAccept: */*\r\n\r\n",path,host,port);
|
||||||
|
if(lwip_send(fd,req,reqlen,0)<0){
|
||||||
|
ESP_LOGE(TAG,"dl send fail errno=%d",errno);
|
||||||
|
fclose(f); close(fd); unlink(tmp_path); return false;
|
||||||
|
}
|
||||||
|
ESP_LOGI(TAG,"dl request sent");
|
||||||
|
|
||||||
|
char* hdr=heap_caps_malloc(8192, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||||
|
if(!hdr){ fclose(f); close(fd); unlink(tmp_path); return false; }
|
||||||
|
int hdr_len=0;
|
||||||
|
bool header_done=false;
|
||||||
|
int content_len=-1;
|
||||||
|
int total=0;
|
||||||
|
uint8_t* recv_buf=heap_caps_malloc(8192, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||||
|
if(!recv_buf){ heap_caps_free(hdr); fclose(f); close(fd); unlink(tmp_path); return false; }
|
||||||
|
|
||||||
|
G.dl_total=0;
|
||||||
|
G.dl_expected=-1;
|
||||||
|
G.dl_last_pct=-1;
|
||||||
|
|
||||||
|
while(!header_done){
|
||||||
|
if(G.dl_cancel_req){ ESP_LOGI(TAG,"dl canceled during header"); heap_caps_free(hdr); heap_caps_free(recv_buf); fclose(f); close(fd); unlink(tmp_path); return false; }
|
||||||
|
int r=lwip_recv(fd,(char*)recv_buf,4096,0);
|
||||||
|
if(r<=0){
|
||||||
|
ESP_LOGE(TAG,"dl recv header fail r=%d errno=%d",r,errno);
|
||||||
|
heap_caps_free(hdr); heap_caps_free(recv_buf); fclose(f); close(fd); unlink(tmp_path); return false;
|
||||||
|
}
|
||||||
|
if(hdr_len+r >= 8191){ ESP_LOGE(TAG,"dl hdr overflow"); heap_caps_free(hdr); heap_caps_free(recv_buf); fclose(f); close(fd); unlink(tmp_path); return false; }
|
||||||
|
memcpy(hdr+hdr_len, recv_buf, r);
|
||||||
|
hdr_len+=r;
|
||||||
|
hdr[hdr_len]=0;
|
||||||
|
char* he=strstr(hdr,"\r\n\r\n");
|
||||||
|
if(he){
|
||||||
|
header_done=true;
|
||||||
|
if(strncmp(hdr,"HTTP/1.1 200",12)!=0 && strncmp(hdr,"HTTP/1.0 200",12)!=0){
|
||||||
|
ESP_LOGE(TAG,"dl not 200: %.120s",hdr);
|
||||||
|
heap_caps_free(hdr); heap_caps_free(recv_buf); fclose(f); close(fd); unlink(tmp_path); return false;
|
||||||
|
}
|
||||||
|
char* cl=strstr(hdr,"Content-Length:");
|
||||||
|
if(!cl) cl=strstr(hdr,"content-length:");
|
||||||
|
if(cl){
|
||||||
|
cl=strchr(cl,':');
|
||||||
|
if(cl){ cl++; while(*cl==' ') cl++; content_len=atoi(cl); }
|
||||||
|
}
|
||||||
|
ESP_LOGI(TAG,"dl header done cl=%d",content_len);
|
||||||
|
G.dl_expected=content_len;
|
||||||
|
int body_start = (he - hdr) + 4;
|
||||||
|
int body_in_buf = hdr_len - body_start;
|
||||||
|
if(body_in_buf>0){
|
||||||
|
fwrite(hdr+body_start,1,body_in_buf,f);
|
||||||
|
total+=body_in_buf;
|
||||||
|
G.dl_total=total;
|
||||||
|
ESP_LOGI(TAG,"dl wrote initial %d",body_in_buf);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ESP_LOGI(TAG,"dl streaming body cl=%d",content_len);
|
||||||
|
int consecutive_timeouts=0;
|
||||||
|
const int max_timeouts=12;
|
||||||
|
while(1){
|
||||||
|
if(G.dl_cancel_req){
|
||||||
|
ESP_LOGI(TAG,"dl canceled during body total=%d",total);
|
||||||
|
heap_caps_free(hdr);
|
||||||
|
heap_caps_free(recv_buf);
|
||||||
|
close(fd);
|
||||||
|
fclose(f);
|
||||||
|
unlink(tmp_path);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if(content_len>=0 && total>=content_len) break;
|
||||||
|
int r=lwip_recv(fd,(char*)recv_buf,8192,0);
|
||||||
|
if(r<0){
|
||||||
|
if(errno==EAGAIN || errno==EWOULDBLOCK || errno==ETIMEDOUT || errno==0){
|
||||||
|
consecutive_timeouts++;
|
||||||
|
ESP_LOGW(TAG,"dl timeout %d/%d total=%d/%d",consecutive_timeouts,max_timeouts,total,content_len);
|
||||||
|
if(consecutive_timeouts>=max_timeouts){
|
||||||
|
ESP_LOGE(TAG,"dl too many timeouts, aborting");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(200));
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
ESP_LOGE(TAG,"dl recv error r=%d errno=%d",r,errno);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(r==0){
|
||||||
|
ESP_LOGI(TAG,"dl EOF total=%d expected=%d",total,content_len);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
consecutive_timeouts=0;
|
||||||
|
fwrite(recv_buf,1,r,f);
|
||||||
|
total+=r;
|
||||||
|
G.dl_total=total;
|
||||||
|
}
|
||||||
|
|
||||||
|
heap_caps_free(hdr);
|
||||||
|
heap_caps_free(recv_buf);
|
||||||
|
close(fd);
|
||||||
|
fclose(f);
|
||||||
|
ESP_LOGI(TAG,"DL done total %d expected %d",total,content_len);
|
||||||
|
if(G.dl_cancel_req){
|
||||||
|
ESP_LOGI(TAG,"DL canceled, removing tmp");
|
||||||
|
unlink(tmp_path);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if(total<1024){ ESP_LOGE(TAG,"dl too small %d",total); unlink(tmp_path); return false; }
|
||||||
|
if(content_len>=0 && total < content_len){
|
||||||
|
if(total < content_len * 95 / 100){
|
||||||
|
ESP_LOGE(TAG,"dl incomplete %d < %d (95%% threshold)",total,content_len);
|
||||||
|
unlink(tmp_path);
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
ESP_LOGW(TAG,"dl size slightly short %d vs %d, accepting",total,content_len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(rename(tmp_path,final_path)!=0){
|
||||||
|
ESP_LOGE(TAG,"rename failed %s -> %s errno=%d",tmp_path,final_path,errno);
|
||||||
|
unlink(tmp_path);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ep->seen=true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool dl_ep(EpInfo* ep){
|
||||||
|
if(!ep) return false;
|
||||||
|
if(has_slug(ep->slug)) return true;
|
||||||
|
ensure_dir();
|
||||||
|
lru_check();
|
||||||
|
return dl_ep_raw(ep);
|
||||||
|
}
|
||||||
|
|
||||||
|
void dl_task(void* arg){
|
||||||
|
int idx = (int)(intptr_t)arg;
|
||||||
|
G.downloading=true;
|
||||||
|
G.dl_cancel_req=false;
|
||||||
|
if(idx<0||idx>=G.ep_cnt){ G.downloading=false; G.dl_handle=NULL; vTaskDelete(NULL); return; }
|
||||||
|
EpInfo* ep=&G.eps[idx];
|
||||||
|
ESP_LOGI(TAG,"DL start %s url %s",ep->slug,ep->audio_url);
|
||||||
|
bool ok=dl_ep(ep);
|
||||||
|
ESP_LOGI(TAG,"DL end %s ok %d",ep->slug,ok);
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
hide_dl_overlay();
|
||||||
|
if(G.lbl_status){
|
||||||
|
if(ok) lv_label_set_text(G.lbl_status,"DL done, playing...");
|
||||||
|
else {
|
||||||
|
if(G.dl_cancel_req) lv_label_set_text(G.lbl_status,"DL canceled");
|
||||||
|
else lv_label_set_text(G.lbl_status,"DL failed – tap Play to retry");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
G.downloading=false;
|
||||||
|
G.dl_handle=NULL;
|
||||||
|
if(ok){
|
||||||
|
start_idx_internal(idx);
|
||||||
|
}
|
||||||
|
vTaskDelete(NULL);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "app_context.h"
|
||||||
|
#include <stdbool.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Raw download to file with progress tracking
|
||||||
|
bool dl_ep_raw(EpInfo* ep);
|
||||||
|
bool dl_ep(EpInfo* ep);
|
||||||
|
|
||||||
|
// Download task
|
||||||
|
void dl_task(void* arg);
|
||||||
|
|
||||||
|
// Download overlay UI
|
||||||
|
void hide_dl_overlay(void);
|
||||||
|
void show_dl_overlay_locked(const char* ep_title, const char* slug, int season, int ep_num);
|
||||||
|
void update_dl_overlay_ui(void);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
#include <tt_app.h>
|
||||||
|
#include <tt_lvgl.h>
|
||||||
|
#include "app_context.h"
|
||||||
|
#include "storage.h"
|
||||||
|
#include "network.h"
|
||||||
|
#include "download.h"
|
||||||
|
#include "player.h"
|
||||||
|
#include "ui.h"
|
||||||
|
|
||||||
|
#include <string.h>
|
||||||
|
#include "freertos/FreeRTOS.h"
|
||||||
|
#include "freertos/task.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
|
||||||
|
static void fetch_task_fn(void* arg){
|
||||||
|
(void)arg;
|
||||||
|
G.fetching=true;
|
||||||
|
ESP_LOGI(TAG,"fetch task start");
|
||||||
|
fetch_data();
|
||||||
|
ESP_LOGI(TAG,"fetch done seasons=%d eps=%d",G.season_cnt,G.ep_cnt);
|
||||||
|
if(strlen(G.last_slug)>0){
|
||||||
|
for(int i=0;i<G.ep_cnt;i++) if(strcmp(G.eps[i].slug,G.last_slug)==0){ G.cur_ep_idx=i; G.cur_season=G.eps[i].season; break; }
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
if(G.ep_cnt>0 && G.cur_ep_idx>=0){
|
||||||
|
EpInfo* ep=&G.eps[G.cur_ep_idx];
|
||||||
|
int saved = get_saved_pos_for_slug(ep->slug);
|
||||||
|
if(saved==0) saved = G.pos_sec;
|
||||||
|
if(G.lbl_title) lv_label_set_text(G.lbl_title,ep->title);
|
||||||
|
if(G.lbl_meta){
|
||||||
|
char b[64];
|
||||||
|
if(saved>5){
|
||||||
|
char tbuf[16];
|
||||||
|
fmt_time(tbuf,saved);
|
||||||
|
snprintf(b,sizeof(b),"S%02d E%02d %s • %s",ep->season,ep->ep_num,ep->seen?"*":"",tbuf);
|
||||||
|
} else {
|
||||||
|
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){
|
||||||
|
if(saved>5) lv_label_set_text(G.lbl_status,"Resume – tap Play");
|
||||||
|
else lv_label_set_text(G.lbl_status,ep->seen?"On SD – tap Play":"Tap Play to DL");
|
||||||
|
}
|
||||||
|
if(G.bar) lv_bar_set_value(G.bar,0,LV_ANIM_OFF);
|
||||||
|
if(G.lbl_time){
|
||||||
|
if(saved>5){
|
||||||
|
char a[16];
|
||||||
|
fmt_time(a,saved);
|
||||||
|
char buf[40];
|
||||||
|
snprintf(buf,sizeof(buf),"%s / --:--",a);
|
||||||
|
lv_label_set_text(G.lbl_time,buf);
|
||||||
|
} else {
|
||||||
|
lv_label_set_text(G.lbl_time,"0:00 / --:--");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(saved>0) G.pos_sec=saved;
|
||||||
|
} else {
|
||||||
|
if(G.lbl_status) lv_label_set_text(G.lbl_status,"No data");
|
||||||
|
}
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
G.fetching=false;
|
||||||
|
G.fetch_handle=NULL;
|
||||||
|
save_state();
|
||||||
|
vTaskDelete(NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void onShow(AppHandle app, void* data, lv_obj_t* parent){
|
||||||
|
(void)app; (void)data; (void)parent;
|
||||||
|
memset(&G,0,sizeof(G));
|
||||||
|
G.pos_sec=0;
|
||||||
|
G.total_sec=0;
|
||||||
|
G.cur_season=1;
|
||||||
|
G.cur_ep_idx=-1;
|
||||||
|
G.volume=80;
|
||||||
|
G.last_pct=-1;
|
||||||
|
G.dl_last_pct=-1;
|
||||||
|
G.dl_expected=-1;
|
||||||
|
ensure_dir();
|
||||||
|
load_state();
|
||||||
|
build_ui();
|
||||||
|
xTaskCreate(fetch_task_fn,"dm_fetch",16384,NULL,5,&G.fetch_handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void onHide(AppHandle app, void* data){
|
||||||
|
(void)app; (void)data;
|
||||||
|
save_current_ep_pos();
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
if(G.fetch_handle){
|
||||||
|
vTaskDelete(G.fetch_handle);
|
||||||
|
G.fetch_handle=NULL;
|
||||||
|
G.fetching=false;
|
||||||
|
}
|
||||||
|
if(G.dl_handle){
|
||||||
|
G.dl_cancel_req=true;
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(100));
|
||||||
|
if(G.dl_handle){
|
||||||
|
vTaskDelete(G.dl_handle);
|
||||||
|
G.dl_handle=NULL;
|
||||||
|
G.downloading=false;
|
||||||
|
if(G.dl_overlay) hide_dl_overlay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wait_play_exit();
|
||||||
|
close_overlay();
|
||||||
|
if(G.dl_overlay) hide_dl_overlay();
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
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
@@ -0,0 +1,249 @@
|
|||||||
|
#include "network.h"
|
||||||
|
#include "storage.h"
|
||||||
|
#include "cJSON.h"
|
||||||
|
#include <string.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <lwip/sockets.h>
|
||||||
|
#include <lwip/inet.h>
|
||||||
|
#include "freertos/FreeRTOS.h"
|
||||||
|
#include "freertos/task.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
uint16_t my_htons(uint16_t v){ return (v<<8)|(v>>8); }
|
||||||
|
|
||||||
|
bool resolve_host(const char* host, uint32_t* out_ip){
|
||||||
|
if(!host || !out_ip) return false;
|
||||||
|
uint32_t ip = ipaddr_addr(host);
|
||||||
|
if(ip!=0 && ip!=0xFFFFFFFF){
|
||||||
|
*out_ip=ip;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
ESP_LOGE(TAG,"resolve fail (DNS not supported) %s",host);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void normalize_audio_url(const char* in, char* out, size_t out_len){
|
||||||
|
if(!in || !out) return;
|
||||||
|
if(strncmp(in,"http://",7)==0){
|
||||||
|
strncpy(out,in,out_len-1);
|
||||||
|
out[out_len-1]=0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(in[0]=='/'){
|
||||||
|
snprintf(out,out_len,"%s%s",PB,in);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(strstr(in,"://")==NULL){
|
||||||
|
if(in[0]=='/') snprintf(out,out_len,"%s%s",PB,in);
|
||||||
|
else snprintf(out,out_len,"%s/%s",PB,in);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
strncpy(out,in,out_len-1);
|
||||||
|
out[out_len-1]=0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int http_get_raw(const char* url, char** out_body){
|
||||||
|
if(!url || strncmp(url,"http://",7)!=0){ ESP_LOGE(TAG,"http_get url not http %s",url); return -1; }
|
||||||
|
const char* p = url+7;
|
||||||
|
const char* slash = strchr(p,'/');
|
||||||
|
if(!slash){ ESP_LOGE(TAG,"no slash %s",url); return -1; }
|
||||||
|
char hostport[128]={0};
|
||||||
|
size_t hlen=slash-p;
|
||||||
|
if(hlen>=sizeof(hostport)) return -1;
|
||||||
|
memcpy(hostport,p,hlen);
|
||||||
|
hostport[hlen]=0;
|
||||||
|
const char* path=slash;
|
||||||
|
char host[96]={0};
|
||||||
|
int port=80;
|
||||||
|
char* colon=strchr(hostport,':');
|
||||||
|
if(colon){ *colon=0; strncpy(host,hostport,sizeof(host)-1); port=atoi(colon+1); if(port<=0) port=80; } else { strncpy(host,hostport,sizeof(host)-1); }
|
||||||
|
ESP_LOGI(TAG,"GET %s host=%s port=%d",url,host,port);
|
||||||
|
uint32_t ip=0;
|
||||||
|
if(!resolve_host(host,&ip)){ ESP_LOGE(TAG,"ip fail %s",host); return -1; }
|
||||||
|
int fd=lwip_socket(AF_INET,SOCK_STREAM,0);
|
||||||
|
if(fd<0){ ESP_LOGE(TAG,"socket fail"); return -1; }
|
||||||
|
struct sockaddr_in sa; memset(&sa,0,sizeof(sa));
|
||||||
|
sa.sin_family=AF_INET; sa.sin_port=my_htons(port); sa.sin_addr.s_addr=ip;
|
||||||
|
struct timeval tv={8,0};
|
||||||
|
lwip_setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&tv,sizeof(tv));
|
||||||
|
lwip_setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&tv,sizeof(tv));
|
||||||
|
if(lwip_connect(fd,(struct sockaddr*)&sa,sizeof(sa))<0){ ESP_LOGE(TAG,"connect fail %s:%d",host,port); close(fd); return -1; }
|
||||||
|
char req[512];
|
||||||
|
int reqlen=snprintf(req,sizeof(req),"GET %s HTTP/1.0\r\nHost: %s:%d\r\nConnection: close\r\nUser-Agent: TactilityDM/0.1\r\nAccept: */*\r\n\r\n",path,host,port);
|
||||||
|
if(lwip_send(fd,req,reqlen,0)<0){ ESP_LOGE(TAG,"send fail"); close(fd); return -1; }
|
||||||
|
|
||||||
|
int capacity=65536;
|
||||||
|
char* buf=heap_caps_malloc(capacity, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||||
|
if(!buf){ ESP_LOGE(TAG,"PSRAM 64k fail"); close(fd); return -1; }
|
||||||
|
int total=0;
|
||||||
|
char* header_end=NULL;
|
||||||
|
int body_start=0;
|
||||||
|
int content_len=-1;
|
||||||
|
bool is_chunked=false;
|
||||||
|
|
||||||
|
while(1){
|
||||||
|
if(total>=capacity-1){
|
||||||
|
int newcap=capacity*2;
|
||||||
|
if(newcap>600000){ ESP_LOGE(TAG,"too large %d",newcap); heap_caps_free(buf); close(fd); return -1; }
|
||||||
|
char* nb=heap_caps_malloc(newcap, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||||
|
if(!nb){ heap_caps_free(buf); close(fd); return -1; }
|
||||||
|
memcpy(nb,buf,total);
|
||||||
|
heap_caps_free(buf);
|
||||||
|
buf=nb;
|
||||||
|
capacity=newcap;
|
||||||
|
}
|
||||||
|
int r=lwip_recv(fd,buf+total,capacity-total-1,0);
|
||||||
|
if(r<=0) break;
|
||||||
|
total+=r;
|
||||||
|
buf[total]=0;
|
||||||
|
if(!header_end){
|
||||||
|
header_end=strstr(buf,"\r\n\r\n");
|
||||||
|
if(header_end){
|
||||||
|
body_start=(header_end-buf)+4;
|
||||||
|
if(strstr(buf,"chunked")) is_chunked=true;
|
||||||
|
char* cl=strstr(buf,"Content-Length:");
|
||||||
|
if(!cl) cl=strstr(buf,"content-length:");
|
||||||
|
if(cl){ cl=strchr(cl,':'); if(cl){ cl++; while(*cl==' ') cl++; content_len=atoi(cl); } }
|
||||||
|
ESP_LOGI(TAG,"header_end chunked=%d cl=%d total=%d",is_chunked,content_len,total);
|
||||||
|
if(content_len>=0 && !is_chunked){
|
||||||
|
if(total-body_start >= content_len) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if(content_len>=0 && !is_chunked){
|
||||||
|
if(total-body_start >= content_len) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close(fd);
|
||||||
|
if(!header_end){ ESP_LOGE(TAG,"no header end total=%d",total); heap_caps_free(buf); return -1; }
|
||||||
|
if(strncmp(buf,"HTTP/1.1 200",12)!=0 && strncmp(buf,"HTTP/1.0 200",12)!=0){ ESP_LOGE(TAG,"not 200: %.60s",buf); heap_caps_free(buf); return -1; }
|
||||||
|
|
||||||
|
int raw_len=total-body_start;
|
||||||
|
char* raw=buf+body_start;
|
||||||
|
char* final_body=NULL;
|
||||||
|
int final_len=0;
|
||||||
|
|
||||||
|
if(is_chunked){
|
||||||
|
final_body=heap_caps_malloc(200000, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||||
|
if(!final_body){ heap_caps_free(buf); return -1; }
|
||||||
|
int pos=0, out=0;
|
||||||
|
while(pos<raw_len){
|
||||||
|
char* crlf=strstr(raw+pos,"\r\n");
|
||||||
|
if(!crlf) break;
|
||||||
|
int off=crlf-(raw+pos);
|
||||||
|
if(off<=0 || off>16){ pos+=off+2; continue; }
|
||||||
|
char hex[16]={0};
|
||||||
|
memcpy(hex,raw+pos,off);
|
||||||
|
char* semi=strchr(hex,';');
|
||||||
|
if(semi) *semi=0;
|
||||||
|
long sz=strtol(hex,NULL,16);
|
||||||
|
if(sz==0) break;
|
||||||
|
pos+=off+2;
|
||||||
|
if(pos+sz>raw_len) sz=raw_len-pos;
|
||||||
|
if(out+sz>=200000) break;
|
||||||
|
memcpy(final_body+out,raw+pos,sz);
|
||||||
|
out+=sz;
|
||||||
|
pos+=sz;
|
||||||
|
if(pos+1<raw_len && raw[pos]=='\r' && raw[pos+1]=='\n') pos+=2;
|
||||||
|
}
|
||||||
|
final_body[out]=0;
|
||||||
|
final_len=out;
|
||||||
|
} else {
|
||||||
|
int bl=raw_len;
|
||||||
|
if(content_len>=0 && bl>content_len) bl=content_len;
|
||||||
|
final_body=heap_caps_malloc(bl+1, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||||
|
if(!final_body){ heap_caps_free(buf); return -1; }
|
||||||
|
memcpy(final_body,raw,bl);
|
||||||
|
final_body[bl]=0;
|
||||||
|
final_len=bl;
|
||||||
|
}
|
||||||
|
heap_caps_free(buf);
|
||||||
|
*out_body=final_body;
|
||||||
|
ESP_LOGI(TAG,"GET ok %d",final_len);
|
||||||
|
return final_len;
|
||||||
|
}
|
||||||
|
|
||||||
|
void fetch_data(){
|
||||||
|
char* js=NULL;
|
||||||
|
char url[256];
|
||||||
|
ESP_LOGI(TAG,"fetch seasons");
|
||||||
|
snprintf(url,sizeof(url),"%s/api/collections/dm_seasons/records?perPage=100&sort=season_num",PB);
|
||||||
|
int len=http_get_raw(url,&js);
|
||||||
|
if(len>0 && js){
|
||||||
|
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++; }
|
||||||
|
}
|
||||||
|
sort_seasons();
|
||||||
|
}
|
||||||
|
cJSON_Delete(root);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ESP_LOGW(TAG,"seasons fetch failed");
|
||||||
|
}
|
||||||
|
if(G.season_cnt==0){
|
||||||
|
ESP_LOGW(TAG,"seasons fallback");
|
||||||
|
for(int i=1;i<=37;i++) G.seasons[i-1].num=i;
|
||||||
|
G.season_cnt=37;
|
||||||
|
}
|
||||||
|
|
||||||
|
ESP_LOGI(TAG,"fetch episodes");
|
||||||
|
snprintf(url,sizeof(url),"%s/api/collections/dm_episodes/records?perPage=400&sort=season_num,episode_num",PB);
|
||||||
|
len=http_get_raw(url,&js);
|
||||||
|
if(len>0 && js){
|
||||||
|
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++;
|
||||||
|
}
|
||||||
|
sort_eps();
|
||||||
|
}
|
||||||
|
cJSON_Delete(root);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ESP_LOGW(TAG,"episodes fetch failed");
|
||||||
|
}
|
||||||
|
if(G.ep_cnt==0){
|
||||||
|
ESP_LOGW(TAG,"episodes fallback");
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "app_context.h"
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
uint16_t my_htons(uint16_t v);
|
||||||
|
bool resolve_host(const char* host, uint32_t* out_ip);
|
||||||
|
void normalize_audio_url(const char* in, char* out, size_t out_len);
|
||||||
|
|
||||||
|
// Robust HTTP GET with PSRAM buffer, returns length and allocates *out_body (must be free'd via free/heap_caps_free)
|
||||||
|
int http_get_raw(const char* url, char** out_body);
|
||||||
|
|
||||||
|
// Fetch seasons and episodes from PB
|
||||||
|
void fetch_data(void);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
#include "player.h"
|
||||||
|
#include "storage.h"
|
||||||
|
#include "download.h"
|
||||||
|
#include "ui.h"
|
||||||
|
|
||||||
|
#include <tt_lvgl.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <lwip/sockets.h>
|
||||||
|
#include "freertos/FreeRTOS.h"
|
||||||
|
#include "freertos/task.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#define MINIMP3_IMPLEMENTATION
|
||||||
|
#define MINIMP3_NO_SIMD
|
||||||
|
#include "minimp3.h"
|
||||||
|
|
||||||
|
bool find_audio_device(){
|
||||||
|
struct Device* dev=device_find_by_name("audio-stream");
|
||||||
|
if(dev){ G.stream_dev=dev; return true; }
|
||||||
|
dev=device_find_first_by_type(&AUDIO_STREAM_TYPE);
|
||||||
|
if(dev){ G.stream_dev=dev; return true; }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
void close_stream(){
|
||||||
|
if(G.stream_handle){
|
||||||
|
audio_stream_close(G.stream_handle);
|
||||||
|
G.stream_handle=NULL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bool open_stream(uint32_t sr, uint8_t ch){
|
||||||
|
close_stream();
|
||||||
|
if(!G.stream_dev && !find_audio_device()) return false;
|
||||||
|
struct AudioStreamConfig cfg={ .sample_rate=sr, .bits_per_sample=16, .channels=ch };
|
||||||
|
error_t err=audio_stream_open_output(G.stream_dev,&cfg,&G.stream_handle);
|
||||||
|
return err==ERROR_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
void wait_play_exit(){
|
||||||
|
if(G.play_handle){
|
||||||
|
G.need_stop=true;
|
||||||
|
while(G.play_handle!=NULL){
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(10));
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close_stream();
|
||||||
|
}
|
||||||
|
|
||||||
|
void stop_playback_sync(){
|
||||||
|
if(G.play_handle){
|
||||||
|
G.need_stop=true;
|
||||||
|
int tries=200;
|
||||||
|
while(G.play_handle!=NULL && tries-- >0){
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close_stream();
|
||||||
|
G.is_playing=false;
|
||||||
|
G.is_paused=false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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];
|
||||||
|
char path[300];
|
||||||
|
make_sd_path(path,sizeof(path),ep->slug);
|
||||||
|
FILE* file=fopen(path,"rb");
|
||||||
|
if(!file){ ESP_LOGE(TAG,"open fail %s",path); 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);
|
||||||
|
if(fsize>0) G.total_sec=(int)(fsize/16000);
|
||||||
|
if(!find_audio_device()){ ESP_LOGE(TAG,"no audio-stream dev"); fclose(file); G.is_playing=false; G.play_handle=NULL; vTaskDelete(NULL); return; }
|
||||||
|
|
||||||
|
uint8_t id3hdr[10];
|
||||||
|
long id3_skip=0;
|
||||||
|
if(fread(id3hdr,1,10,file)==10 && memcmp(id3hdr,"ID3",3)==0){
|
||||||
|
int id3size = (id3hdr[6]&0x7F)<<21 | (id3hdr[7]&0x7F)<<14 | (id3hdr[8]&0x7F)<<7 | (id3hdr[9]&0x7F);
|
||||||
|
ESP_LOGI(TAG,"ID3 found %d, skip",id3size);
|
||||||
|
id3_skip=id3size+10;
|
||||||
|
fseek(file, id3_skip, SEEK_SET);
|
||||||
|
fsize -= id3_skip;
|
||||||
|
if(fsize>0) G.total_sec=(int)(fsize/16000);
|
||||||
|
} else {
|
||||||
|
fseek(file,0,SEEK_SET);
|
||||||
|
}
|
||||||
|
|
||||||
|
int resume_sec = G.pos_sec;
|
||||||
|
if(resume_sec>5){
|
||||||
|
long seek_bytes = (long)resume_sec * 16000;
|
||||||
|
if(seek_bytes < fsize){
|
||||||
|
fseek(file, id3_skip + seek_bytes, SEEK_SET);
|
||||||
|
ESP_LOGI(TAG,"Resuming %s from %d sec (~%ld bytes)", ep->slug, resume_sec, seek_bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mp3dec_t* dec = heap_caps_malloc(sizeof(mp3dec_t), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||||
|
if(!dec){ ESP_LOGE(TAG,"dec malloc fail"); fclose(file); G.play_handle=NULL; vTaskDelete(NULL); return; }
|
||||||
|
mp3dec_init(dec);
|
||||||
|
uint8_t* inbuf=malloc(MP3_BUF);
|
||||||
|
int16_t* pcm=malloc(1152*2*2);
|
||||||
|
if(!inbuf||!pcm){ if(inbuf) free(inbuf); if(pcm) free(pcm); fclose(file); heap_caps_free(dec); G.play_handle=NULL; vTaskDelete(NULL); return; }
|
||||||
|
size_t buffered=0;
|
||||||
|
mp3dec_frame_info_t info;
|
||||||
|
memset(&info,0,sizeof(info));
|
||||||
|
bool configured=false;
|
||||||
|
int sr=0, ch=0;
|
||||||
|
size_t r=fread(inbuf,1,MP3_BUF,file);
|
||||||
|
buffered=r;
|
||||||
|
ESP_LOGI(TAG,"play task start %s size %ld resume=%d",path,fsize,resume_sec);
|
||||||
|
long total_decoded_samples = 0;
|
||||||
|
if(resume_sec>5){
|
||||||
|
total_decoded_samples = (long)resume_sec * 16000;
|
||||||
|
}
|
||||||
|
|
||||||
|
while(!G.need_stop){
|
||||||
|
if(G.is_paused){
|
||||||
|
if(configured){
|
||||||
|
close_stream();
|
||||||
|
configured=false;
|
||||||
|
sr=0; ch=0;
|
||||||
|
}
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(100));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int samples=mp3dec_decode_frame(dec,inbuf,(int)buffered,pcm,&info);
|
||||||
|
if(samples>0){
|
||||||
|
total_decoded_samples += samples;
|
||||||
|
if(info.hz>0) G.pos_sec = total_decoded_samples / info.hz;
|
||||||
|
else G.pos_sec = total_decoded_samples / 16000;
|
||||||
|
if(!configured || info.hz!=sr || info.channels!=ch){
|
||||||
|
ESP_LOGI(TAG,"open_stream sr=%d ch=%d",info.hz,info.channels);
|
||||||
|
if(!open_stream((uint32_t)info.hz,(uint8_t)info.channels)){
|
||||||
|
ESP_LOGE(TAG,"open_stream fail %d %d",info.hz,info.channels);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
ESP_LOGI(TAG,"open_stream ok");
|
||||||
|
sr=info.hz; ch=info.channels;
|
||||||
|
configured=true;
|
||||||
|
if(resume_sec>5 && total_decoded_samples < (long)resume_sec * info.hz + info.hz){
|
||||||
|
total_decoded_samples = (long)resume_sec * info.hz;
|
||||||
|
G.pos_sec = resume_sec;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
float vol=G.volume/100.0f;
|
||||||
|
if(vol<0) vol=0;
|
||||||
|
if(vol>1) vol=1;
|
||||||
|
int total_samples = samples * (info.channels>0?info.channels:1);
|
||||||
|
for(int i=0;i<total_samples;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 = total_samples * sizeof(int16_t);
|
||||||
|
size_t off=0;
|
||||||
|
while(off<to_write && !G.need_stop){
|
||||||
|
if(G.is_paused){
|
||||||
|
if(configured){
|
||||||
|
close_stream();
|
||||||
|
configured=false;
|
||||||
|
sr=0; ch=0;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
size_t w=0;
|
||||||
|
error_t e=audio_stream_write(G.stream_handle,(uint8_t*)pcm+off,to_write-off,&w,pdMS_TO_TICKS(500));
|
||||||
|
if(e==ERROR_NONE){
|
||||||
|
off+=w;
|
||||||
|
} else {
|
||||||
|
ESP_LOGW(TAG,"audio_write err %d",e);
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(G.is_paused) continue;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
heap_caps_free(dec);
|
||||||
|
close_stream();
|
||||||
|
bool finished_naturally = !G.need_stop;
|
||||||
|
if(finished_naturally){
|
||||||
|
ESP_LOGI(TAG,"playback finished naturally for %s, clearing saved pos", ep->slug);
|
||||||
|
set_saved_pos_for_slug(ep->slug, 0);
|
||||||
|
G.pos_sec=0;
|
||||||
|
save_state();
|
||||||
|
} else {
|
||||||
|
set_saved_pos_for_slug(ep->slug, G.pos_sec);
|
||||||
|
save_state();
|
||||||
|
}
|
||||||
|
ESP_LOGI(TAG,"play task end finished_naturally=%d", finished_naturally);
|
||||||
|
G.is_playing=false;
|
||||||
|
G.play_handle=NULL;
|
||||||
|
vTaskDelete(NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
void start_idx_internal(int idx){
|
||||||
|
if(idx<0||idx>=G.ep_cnt) return;
|
||||||
|
EpInfo* ep=&G.eps[idx];
|
||||||
|
if(G.cur_ep_idx!=idx){
|
||||||
|
int saved = get_saved_pos_for_slug(ep->slug);
|
||||||
|
if(saved>5){
|
||||||
|
G.pos_sec=saved;
|
||||||
|
ESP_LOGI(TAG,"Restoring saved pos %d for %s", saved, ep->slug);
|
||||||
|
}
|
||||||
|
G.last_pct=-1;
|
||||||
|
}
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
wait_play_exit();
|
||||||
|
G.cur_ep_idx=idx; G.cur_season=ep->season;
|
||||||
|
strncpy(G.cur_title,ep->title,sizeof(G.cur_title)-1);
|
||||||
|
strncpy(G.last_slug,ep->slug,sizeof(G.last_slug)-1);
|
||||||
|
G.need_stop=false; G.is_paused=false; G.is_playing=true;
|
||||||
|
save_state();
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
xTaskCreate(play_task,"dm_play",16384,NULL,5,&G.play_handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
void start_idx(int idx){
|
||||||
|
if(idx<0||idx>=G.ep_cnt) return;
|
||||||
|
EpInfo* ep=&G.eps[idx];
|
||||||
|
if(!has_slug(ep->slug)){
|
||||||
|
stop_playback_sync();
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
show_dl_overlay_locked(ep->title, ep->slug, ep->season, ep->ep_num);
|
||||||
|
if(G.lbl_status) lv_label_set_text(G.lbl_status,"Downloading…");
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
if(G.dl_handle){ return; }
|
||||||
|
xTaskCreate(dl_task,"dm_dl",16384,(void*)(intptr_t)idx,5,&G.dl_handle);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
start_idx_internal(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
void select_ep(int idx){
|
||||||
|
if(idx<0||idx>=G.ep_cnt) return;
|
||||||
|
if(G.cur_ep_idx>=0 && G.cur_ep_idx < G.ep_cnt && G.cur_ep_idx!=idx){
|
||||||
|
save_current_ep_pos();
|
||||||
|
}
|
||||||
|
G.cur_ep_idx=idx;
|
||||||
|
EpInfo* ep=&G.eps[idx];
|
||||||
|
G.cur_season=ep->season;
|
||||||
|
strncpy(G.last_slug, ep->slug, sizeof(G.last_slug)-1);
|
||||||
|
|
||||||
|
int saved = get_saved_pos_for_slug(ep->slug);
|
||||||
|
if(saved>5){
|
||||||
|
G.pos_sec=saved;
|
||||||
|
ESP_LOGI(TAG,"select_ep restore %s pos=%d", ep->slug, saved);
|
||||||
|
} else {
|
||||||
|
G.pos_sec=saved;
|
||||||
|
}
|
||||||
|
G.last_pct=-1;
|
||||||
|
G.total_sec=0;
|
||||||
|
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
if(G.lbl_title) lv_label_set_text(G.lbl_title,ep->title);
|
||||||
|
if(G.lbl_meta){
|
||||||
|
char b[64];
|
||||||
|
if(saved>5){
|
||||||
|
char tbuf[16];
|
||||||
|
fmt_time(tbuf,saved);
|
||||||
|
snprintf(b,sizeof(b),"S%02d E%02d %s • %s",ep->season,ep->ep_num,ep->seen?"*":"",tbuf);
|
||||||
|
} else {
|
||||||
|
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){
|
||||||
|
if(saved>5) lv_label_set_text(G.lbl_status,"Resume – tap Play");
|
||||||
|
else lv_label_set_text(G.lbl_status,ep->seen?"On SD – tap Play":"Tap Play to download");
|
||||||
|
}
|
||||||
|
if(G.bar){
|
||||||
|
lv_bar_set_value(G.bar,0,LV_ANIM_OFF);
|
||||||
|
}
|
||||||
|
if(G.lbl_time){
|
||||||
|
if(saved>5){
|
||||||
|
char a[16];
|
||||||
|
fmt_time(a,saved);
|
||||||
|
char buf[40];
|
||||||
|
snprintf(buf,sizeof(buf),"%s / --:--",a);
|
||||||
|
lv_label_set_text(G.lbl_time,buf);
|
||||||
|
} else {
|
||||||
|
lv_label_set_text(G.lbl_time,"0:00 / --:--");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
save_state();
|
||||||
|
}
|
||||||
|
|
||||||
|
void go_next(){
|
||||||
|
if(G.ep_cnt==0) return;
|
||||||
|
save_current_ep_pos();
|
||||||
|
save_state();
|
||||||
|
stop_playback_sync();
|
||||||
|
int n=G.cur_ep_idx+1;
|
||||||
|
if(n>=G.ep_cnt) n=0;
|
||||||
|
select_ep(n);
|
||||||
|
start_idx(n);
|
||||||
|
}
|
||||||
|
void go_prev(){
|
||||||
|
if(G.ep_cnt==0) return;
|
||||||
|
if(G.pos_sec>5){
|
||||||
|
ESP_LOGI(TAG,"go_prev restart current %d sec",G.pos_sec);
|
||||||
|
if(G.cur_ep_idx>=0){
|
||||||
|
set_saved_pos_for_slug(G.eps[G.cur_ep_idx].slug, 0);
|
||||||
|
save_state();
|
||||||
|
}
|
||||||
|
stop_playback_sync();
|
||||||
|
G.pos_sec=0;
|
||||||
|
int cur=G.cur_ep_idx;
|
||||||
|
if(cur<0) cur=0;
|
||||||
|
select_ep(cur);
|
||||||
|
start_idx(cur);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
save_current_ep_pos();
|
||||||
|
save_state();
|
||||||
|
stop_playback_sync();
|
||||||
|
int p=G.cur_ep_idx-1;
|
||||||
|
if(p<0) p=G.ep_cnt-1;
|
||||||
|
select_ep(p);
|
||||||
|
start_idx(p);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "app_context.h"
|
||||||
|
#include <stdbool.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
bool find_audio_device(void);
|
||||||
|
void close_stream(void);
|
||||||
|
bool open_stream(uint32_t sr, uint8_t ch);
|
||||||
|
void wait_play_exit(void);
|
||||||
|
void stop_playback_sync(void);
|
||||||
|
|
||||||
|
void play_task(void* arg);
|
||||||
|
void start_idx_internal(int idx);
|
||||||
|
void start_idx(int idx);
|
||||||
|
void select_ep(int idx);
|
||||||
|
void go_next(void);
|
||||||
|
void go_prev(void);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
#include "storage.h"
|
||||||
|
#include "cJSON.h"
|
||||||
|
#include <string.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <dirent.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
void make_sd_path(char* out, size_t out_len, const char* slug){
|
||||||
|
if(!out || !slug) return;
|
||||||
|
snprintf(out, out_len, "%s/%s.mp3", DM_DIR, slug);
|
||||||
|
}
|
||||||
|
void ensure_dir(){
|
||||||
|
mkdir("/sdcard/apps",0777);
|
||||||
|
mkdir("/sdcard/apps/one.tactility.discoverymountain",0777);
|
||||||
|
mkdir("/sdcard/apps/one.tactility.discoverymountain/userdata",0777);
|
||||||
|
mkdir(DM_DIR,0777);
|
||||||
|
}
|
||||||
|
bool has_slug(const char* slug){
|
||||||
|
char p[300];
|
||||||
|
make_sd_path(p, sizeof(p), slug);
|
||||||
|
struct stat st;
|
||||||
|
return stat(p,&st)==0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void fmt_time(char* o,int s){ snprintf(o,16,"%d:%02d",s/60,s%60); }
|
||||||
|
|
||||||
|
void sort_seasons(){
|
||||||
|
for(int i=0;i<G.season_cnt;i++){
|
||||||
|
for(int j=i+1;j<G.season_cnt;j++){
|
||||||
|
if(G.seasons[j].num < G.seasons[i].num){
|
||||||
|
SeasonInfo t=G.seasons[i]; G.seasons[i]=G.seasons[j]; G.seasons[j]=t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void sort_eps(){
|
||||||
|
for(int i=0;i<G.ep_cnt;i++){
|
||||||
|
for(int j=i+1;j<G.ep_cnt;j++){
|
||||||
|
EpInfo *a=&G.eps[i], *b=&G.eps[j];
|
||||||
|
bool should_swap=false;
|
||||||
|
if(a->season != b->season) should_swap = (b->season < a->season);
|
||||||
|
else should_swap = (b->ep_num < a->ep_num);
|
||||||
|
if(should_swap){
|
||||||
|
EpInfo t=*a; *a=*b; *b=t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-episode position helpers ──
|
||||||
|
int find_saved_pos_idx(const char* slug){
|
||||||
|
if(!slug) return -1;
|
||||||
|
for(int i=0;i<G.saved_pos_cnt;i++){
|
||||||
|
if(strcmp(G.saved_pos[i].slug, slug)==0) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
int get_saved_pos_for_slug(const char* slug){
|
||||||
|
int idx=find_saved_pos_idx(slug);
|
||||||
|
if(idx>=0) return G.saved_pos[idx].pos;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
void set_saved_pos_for_slug(const char* slug, int pos){
|
||||||
|
if(!slug || strlen(slug)==0) return;
|
||||||
|
if(pos<0) pos=0;
|
||||||
|
if(G.total_sec>0 && pos>0 && pos >= G.total_sec-10){
|
||||||
|
pos=0;
|
||||||
|
}
|
||||||
|
int idx=find_saved_pos_idx(slug);
|
||||||
|
if(idx>=0){
|
||||||
|
G.saved_pos[idx].pos=pos;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(G.saved_pos_cnt < MAX_EPS){
|
||||||
|
strncpy(G.saved_pos[G.saved_pos_cnt].slug, slug, sizeof(G.saved_pos[G.saved_pos_cnt].slug)-1);
|
||||||
|
G.saved_pos[G.saved_pos_cnt].slug[sizeof(G.saved_pos[G.saved_pos_cnt].slug)-1]=0;
|
||||||
|
G.saved_pos[G.saved_pos_cnt].pos=pos;
|
||||||
|
G.saved_pos_cnt++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void save_current_ep_pos(){
|
||||||
|
if(G.cur_ep_idx>=0 && G.cur_ep_idx < G.ep_cnt){
|
||||||
|
const char* slug=G.eps[G.cur_ep_idx].slug;
|
||||||
|
set_saved_pos_for_slug(slug, G.pos_sec);
|
||||||
|
} else if(strlen(G.last_slug)>0){
|
||||||
|
set_saved_pos_for_slug(G.last_slug, G.pos_sec);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void save_state(){
|
||||||
|
if(G.cur_ep_idx>=0 && G.ep_cnt>0){
|
||||||
|
if(G.pos_sec>=0){
|
||||||
|
set_saved_pos_for_slug(G.eps[G.cur_ep_idx].slug, G.pos_sec);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cJSON* r=cJSON_CreateObject();
|
||||||
|
if(G.ep_cnt>0 && G.cur_ep_idx>=0 && G.cur_ep_idx < G.ep_cnt) cJSON_AddStringToObject(r,"last_slug",G.eps[G.cur_ep_idx].slug);
|
||||||
|
else if(strlen(G.last_slug)>0) cJSON_AddStringToObject(r,"last_slug",G.last_slug);
|
||||||
|
cJSON_AddNumberToObject(r,"last_season",G.cur_season);
|
||||||
|
cJSON_AddNumberToObject(r,"pos_sec",G.pos_sec);
|
||||||
|
if(G.saved_pos_cnt>0){
|
||||||
|
cJSON* pos_obj=cJSON_CreateObject();
|
||||||
|
for(int i=0;i<G.saved_pos_cnt;i++){
|
||||||
|
if(G.saved_pos[i].slug[0]==0) continue;
|
||||||
|
if(G.saved_pos[i].pos>0){
|
||||||
|
cJSON_AddNumberToObject(pos_obj, G.saved_pos[i].slug, G.saved_pos[i].pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cJSON_AddItemToObject(r,"positions",pos_obj);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
void load_state(){
|
||||||
|
G.cur_season=1; G.cur_ep_idx=-1; G.pos_sec=0;
|
||||||
|
G.saved_pos_cnt=0;
|
||||||
|
G.last_save_tick=0;
|
||||||
|
memset(G.last_slug,0,sizeof(G.last_slug));
|
||||||
|
memset(G.saved_pos,0,sizeof(G.saved_pos));
|
||||||
|
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>16384){ fclose(f); return;}
|
||||||
|
char* buf=malloc(sz+1);
|
||||||
|
if(!buf){ fclose(f); return; }
|
||||||
|
size_t got=fread(buf,1,sz,f);
|
||||||
|
buf[got]=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)){
|
||||||
|
strncpy(G.last_slug, ls->valuestring, sizeof(G.last_slug)-1);
|
||||||
|
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* poss=cJSON_GetObjectItem(r,"positions");
|
||||||
|
if(poss && cJSON_IsObject(poss)){
|
||||||
|
cJSON* child=NULL;
|
||||||
|
cJSON_ArrayForEach(child, poss){
|
||||||
|
if(cJSON_IsNumber(child) && child->string && G.saved_pos_cnt < MAX_EPS){
|
||||||
|
strncpy(G.saved_pos[G.saved_pos_cnt].slug, child->string, sizeof(G.saved_pos[G.saved_pos_cnt].slug)-1);
|
||||||
|
G.saved_pos[G.saved_pos_cnt].pos=child->valueint;
|
||||||
|
G.saved_pos_cnt++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(strlen(G.last_slug)>0){
|
||||||
|
int sp=get_saved_pos_for_slug(G.last_slug);
|
||||||
|
if(sp>0) G.pos_sec=sp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cJSON_Delete(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
char cur_path[300]={0};
|
||||||
|
if(G.cur_ep_idx>=0 && G.cur_ep_idx<G.ep_cnt){
|
||||||
|
make_sd_path(cur_path, sizeof(cur_path), G.eps[G.cur_ep_idx].slug);
|
||||||
|
}
|
||||||
|
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(cur_path[0] && strcmp(full,cur_path)==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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "app_context.h"
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Path helpers
|
||||||
|
void make_sd_path(char* out, size_t out_len, const char* slug);
|
||||||
|
void ensure_dir(void);
|
||||||
|
bool has_slug(const char* slug);
|
||||||
|
|
||||||
|
// LRU cache cleanup
|
||||||
|
void lru_check(void);
|
||||||
|
|
||||||
|
// Sorting
|
||||||
|
void sort_seasons(void);
|
||||||
|
void sort_eps(void);
|
||||||
|
|
||||||
|
// Time formatting
|
||||||
|
void fmt_time(char* o, int s);
|
||||||
|
|
||||||
|
// Per-episode resume helpers
|
||||||
|
int find_saved_pos_idx(const char* slug);
|
||||||
|
int get_saved_pos_for_slug(const char* slug);
|
||||||
|
void set_saved_pos_for_slug(const char* slug, int pos);
|
||||||
|
void save_current_ep_pos(void);
|
||||||
|
|
||||||
|
// Persistence
|
||||||
|
void save_state(void);
|
||||||
|
void load_state(void);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
#include "ui.h"
|
||||||
|
#include "storage.h"
|
||||||
|
#include "player.h"
|
||||||
|
#include "download.h"
|
||||||
|
|
||||||
|
#include <tt_lvgl.h>
|
||||||
|
#include <tactility/lvgl_fonts.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include "esp_log.h"
|
||||||
|
|
||||||
|
void close_overlay(){
|
||||||
|
if(G.overlay){
|
||||||
|
lv_obj_delete(G.overlay);
|
||||||
|
G.overlay=NULL;
|
||||||
|
G.dropdown=NULL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void dropdown_cb(lv_event_t* e){
|
||||||
|
if(lv_event_get_code(e)!=LV_EVENT_VALUE_CHANGED) return;
|
||||||
|
lv_obj_t* dd = (lv_obj_t*)lv_event_get_target(e);
|
||||||
|
int sel=lv_dropdown_get_selected(dd);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void btn_seasons_cb(lv_event_t* e){
|
||||||
|
(void)e;
|
||||||
|
if(G.downloading) return;
|
||||||
|
if(G.overlay){ close_overlay(); return; }
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
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++){
|
||||||
|
char line[16];
|
||||||
|
if(i==0) snprintf(opts,sizeof(opts),"S%d",G.seasons[i].num);
|
||||||
|
else { snprintf(line,sizeof(line),"\nS%d",G.seasons[i].num); strncat(opts,line,sizeof(opts)-strlen(opts)-1); }
|
||||||
|
}
|
||||||
|
G.dropdown=lv_dropdown_create(G.overlay);
|
||||||
|
lv_dropdown_set_options(G.dropdown, opts);
|
||||||
|
lv_obj_set_size(G.dropdown,160,40);
|
||||||
|
lv_obj_align(G.dropdown,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_dropdown_set_selected(G.dropdown, sel);
|
||||||
|
lv_obj_add_event_cb(G.dropdown,dropdown_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);
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
}
|
||||||
|
|
||||||
|
void build_ui(){
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
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);
|
||||||
|
lv_obj_remove_flag(root, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
|
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, lvgl_get_text_font(FONT_SIZE_DEFAULT), 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,"Loading...");
|
||||||
|
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,28);
|
||||||
|
|
||||||
|
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,50);
|
||||||
|
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,66);
|
||||||
|
|
||||||
|
G.lbl_status=lv_label_create(root);
|
||||||
|
lv_label_set_text(G.lbl_status,"Fetching data...");
|
||||||
|
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,82);
|
||||||
|
|
||||||
|
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,104);
|
||||||
|
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_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
|
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_remove_flag(row2, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
|
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,200,NULL);
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ui_timer(lv_timer_t* t){
|
||||||
|
(void)t;
|
||||||
|
if(G.downloading){
|
||||||
|
update_dl_overlay_ui();
|
||||||
|
}
|
||||||
|
if(G.bar){
|
||||||
|
if(G.total_sec>0){
|
||||||
|
int pct=G.pos_sec*100/G.total_sec;
|
||||||
|
if(pct>100) pct=100;
|
||||||
|
if(pct!=G.last_pct){
|
||||||
|
G.last_pct=pct;
|
||||||
|
lv_bar_set_value(G.bar,pct,LV_ANIM_OFF);
|
||||||
|
}
|
||||||
|
} else if(G.is_playing){
|
||||||
|
int pct=(G.pos_sec%10)*10;
|
||||||
|
if(pct!=G.last_pct){
|
||||||
|
G.last_pct=pct;
|
||||||
|
lv_bar_set_value(G.bar,pct,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 && !G.downloading){
|
||||||
|
const char* cur = lv_label_get_text(G.lbl_status);
|
||||||
|
if(!cur || strcmp(cur,"Playing")!=0) lv_label_set_text(G.lbl_status,"Playing");
|
||||||
|
}
|
||||||
|
if(G.is_playing && !G.is_paused && !G.downloading){
|
||||||
|
G.last_save_tick++;
|
||||||
|
if(G.last_save_tick >= 25){
|
||||||
|
G.last_save_tick=0;
|
||||||
|
save_current_ep_pos();
|
||||||
|
save_state();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void btn_play_cb(lv_event_t* e){
|
||||||
|
(void)e;
|
||||||
|
if(G.downloading){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(G.is_playing && !G.is_paused){
|
||||||
|
G.is_paused=true;
|
||||||
|
save_current_ep_pos();
|
||||||
|
save_state();
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
} else if(G.is_paused){
|
||||||
|
G.is_paused=false;
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
} else {
|
||||||
|
start_idx(G.cur_ep_idx);
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void btn_prev_cb(lv_event_t* e){ (void)e; if(G.downloading) return; go_prev(); }
|
||||||
|
void btn_next_cb(lv_event_t* e){ (void)e; if(G.downloading) return; go_next(); }
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "app_context.h"
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
void build_ui(void);
|
||||||
|
void ui_timer(lv_timer_t* t);
|
||||||
|
|
||||||
|
void btn_play_cb(lv_event_t* e);
|
||||||
|
void btn_prev_cb(lv_event_t* e);
|
||||||
|
void btn_next_cb(lv_event_t* e);
|
||||||
|
void btn_seasons_cb(lv_event_t* e);
|
||||||
|
void close_overlay(void);
|
||||||
|
void dropdown_cb(lv_event_t* e);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
manifest.version=0.2
|
||||||
|
target.sdk=0.8.0-dev
|
||||||
|
target.platforms=esp32s3
|
||||||
|
app.id=one.tactility.discoverymountain
|
||||||
|
app.name=Discovery Mountain
|
||||||
|
app.version.name=0.1.0
|
||||||
|
app.version.code=1
|
||||||
@@ -1,988 +0,0 @@
|
|||||||
#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;
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
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
|
|
||||||
Reference in New Issue
Block a user