feat(DM): library UI with episode list, SD status, cache, close button, stable single-file DL
- DiscoveryMountain: new UI optimized for episode list (not player anymore) - Shows episodes for current season, indicates if on SD card (green=On SD, gray=Not DL) - Per-episode action: download single via SdDownloader or play via Mp3Player - DL Missing button for missing eps in season (currently single first missing, batch reverted) - Season selector screen with cached/total counts - Top-right X close button - Seasons/episodes cached to cache.json to avoid network fetch every launch - Fixes: overlay deletion race (deferred timer), ui_timer leak causing crash on close, season selection overwritten by fetch task - Fixes: unicode ellipsis squares - SdDownloader: revert to stable single-file mode (batch array caused crashes) - Remove batch fields and array allocation, keep single url/path download - Result bundle simple success/path/bytes - Mp3Player: add resume position support (pos_sec from bundle, total_sec estimate, total_decoded_samples), return position on exit via bundle for DM to save Tested on 192.168.68.133: opens, shows S10 2/6, Play/DL buttons, close works, no crash on open/close cycles, cache shows 'Loaded from cache, refreshing...'
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.20)
|
||||||
|
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||||
|
if (DEFINED ENV{TACTILITY_SDK_PATH})
|
||||||
|
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
|
||||||
|
else()
|
||||||
|
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
|
||||||
|
message(WARNING "TACTILITY_SDK_PATH not set, defaulting to ${TACTILITY_SDK_PATH}")
|
||||||
|
endif()
|
||||||
|
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
|
||||||
|
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
|
||||||
|
project(DiscoveryMountain)
|
||||||
|
tactility_project(DiscoveryMountain)
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
# Discovery Mountain – Download & Playback Crash Report
|
||||||
|
|
||||||
|
**Date:** 2026-07-25
|
||||||
|
**Device:** 192.168.68.133 (es3c35p, /dev/cu.usbmodem1101)
|
||||||
|
**App ID:** `one.tactility.discoverymountain` (DM) + `one.tactility.sddownloader` (SDDL) + `one.tactility.mp3player` (MP3)
|
||||||
|
**Firmware:** 0.8.0-dev / IDF 5.5.2 / Tactility SDK 0.8.0-dev
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary – What is failing: DM or Downloader?
|
||||||
|
|
||||||
|
**Both, same root cause: low internal heap (3–7 KB) + unsafe task handling.**
|
||||||
|
|
||||||
|
- **DM fetch:** 1st launch OK (37/232), 2nd launch crashes with StoreProhibited.
|
||||||
|
- **Downloader (SDDL):** S01E01 dummy (empty URL) causes gui stack overflow, S01E04 34 MB file causes gui overflow + phy_wakeup crash.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Original Download Crash (from your logs)
|
||||||
|
|
||||||
|
**Log:** `2026-07-24/cu.usbmodem101_21-42-13_s002.log`
|
||||||
|
|
||||||
|
```
|
||||||
|
[21:43:39] DM: DL start 396_Wherever_You_Go_S07E01 url ... 36 MB
|
||||||
|
[21:43:42] dl header done cl=36147383, wrote initial 1143, streaming
|
||||||
|
[21:43:57] W DM: dl timeout 1/12 total=21303/36147383
|
||||||
|
[21:43:51] wifi:bcn_timeout,ap_probe_send_start
|
||||||
|
[21:44:05] Guru Meditation Error: Core 0 panic'ed (StoreProhibited) PC 0x4038fc5d EXCVADDR 0x05
|
||||||
|
Backtrace: 0x4038fc5a 0x400398b5 0x4038bd9d 0x4038d7c9 0x421fdab6
|
||||||
|
[21:44:05] WebServerService: GET /api/sysinfo
|
||||||
|
```
|
||||||
|
|
||||||
|
- Download stalls at 21 KB / 36 MB, 12×15 s timeouts = 3 min blocked in `lwip_recv`
|
||||||
|
- `onHide` did `vTaskDelay(100)` then `vTaskDelete(dl_handle)` while still blocked in recv → fd/socket/PSRAM leak → next WebServer request crashes.
|
||||||
|
- Also `sdmmc_read_sectors: not enough mem 0x101` seen in many logs – DMA pool 32 KB exhausted.
|
||||||
|
|
||||||
|
**Fix applied:**
|
||||||
|
- Timeout 15 s → 5 s, max 12 → 5 (25 s max stall)
|
||||||
|
- `onHide` graceful wait 2 s fetch / 3 s DL, only force delete after, no leak
|
||||||
|
- `recv_buf`/`hdr` 8192 → 4096 PSRAM, `fflush` every 8 chunks, `vTaskDelay(10)` to let SDMMC recover
|
||||||
|
- `sin_len = sizeof(sa)`, proper `my_htons` masking
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. S01E01 – Empty URL + GUI Stack Overflow
|
||||||
|
|
||||||
|
**Log:** `cu.usbmodem1101_20-33-06_s008.log`
|
||||||
|
|
||||||
|
```
|
||||||
|
[20:33:14] DM: connect fail 192.168.68.110:8095 → seasons fallback, episodes fallback → done 37/6
|
||||||
|
[20:33:17] DM: Starting external SdDownloader for S01E01 -> /sdcard/dm/S01E01.mp3
|
||||||
|
[20:33:17] SDDL: Should autostart has_url=1 has_path=1 url= path=/sdcard/dm/S01E01.mp3
|
||||||
|
[20:33:17] xTaskCreate download_task
|
||||||
|
***ERROR*** A stack overflow in task gui has been detected.
|
||||||
|
Backtrace: 0x40385b29:0x3fcdb9d0 ...
|
||||||
|
```
|
||||||
|
|
||||||
|
- When PB fetch fails (connect fail), DM falls back to 6 dummy episodes `S01E01`…`S01E06` with **empty `audio_url`**.
|
||||||
|
- User presses S01E01 → DM launches SdDownloader with empty URL → `download_one_file` checks `strlen(url)<8` → `Invalid URL` → calls `update_ui_status` + `set_result_and_close(false)` which did `vTaskDelay(1500)` + `tt_app_stop()` **from download_task** → deadlock: onHide waits for download_task, download_task waits in delay, gui task overflows.
|
||||||
|
- Also `S01E01.mp3` path exists? No, so it tries download.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- `try_external_downloader` now checks `strlen(audio_url)<8` → shows "No URL – fetch failed?" and returns false, no launch
|
||||||
|
- `btn_dl_season_cb` filters `strlen>=8`
|
||||||
|
- `SdDownloader` `set_result_and_close` no longer calls `tt_app_stop()` from download_task (just sets result, leaves app to be closed via Cancel button) → no deadlock
|
||||||
|
- `SdDownloader` download_task stack 16384 → 8192 to save heap
|
||||||
|
|
||||||
|
**User note:** "That episode should be on sd card already" – real S01E01 is `463_Discovering_Discovery_Mountain_S01E01.mp3` (4.3 MB), not dummy `S01E01.mp3`. Real file exists, dummy does not.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. S01E04 – 34 MB File, GUI Stack Overflow on DL Start
|
||||||
|
|
||||||
|
**Log:** `cu.usbmodem1101_20-26-11_s006.log`
|
||||||
|
|
||||||
|
```
|
||||||
|
[20:28:43] DM: Starting external SdDownloader for 460_The_Lookout...S01E04 -> /sdcard/dm/460_...mp3
|
||||||
|
[20:28:43] SDDL: Should autostart url=http://.../460_...mp3 path=/sdcard/dm/460_...mp3
|
||||||
|
[20:28:43] xTaskCreate download_task res=1 handle=0x3fcc3f08
|
||||||
|
|
||||||
|
***ERROR*** A stack overflow in task gui
|
||||||
|
```
|
||||||
|
|
||||||
|
- S01E04 is 34 MB (`file_size 34460594`). Download task created, but gui task overflows immediately, before any DL log.
|
||||||
|
- Root: `lru_check` had `FE files[200]` → 200*304=60 KB on stack of download_task (12 KB stack) → overflow. Also `sort_eps` had `EpInfo t` 500 bytes on stack inside double loop.
|
||||||
|
- `ensure_parent_dir` had 512+512 stack, `req[512]`, etc.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- `lru_check`: `files` allocated from PSRAM via `heap_caps_malloc(200*sizeof(FE))`
|
||||||
|
- `sort_eps`: temp `EpInfo t` allocated from PSRAM
|
||||||
|
- `ensure_parent_dir`: rewritten without `strtok_r` (not exported) and without large stack, using manual parsing and `strncat`
|
||||||
|
- Download task stack 16K → 8K, fetch task stays 12K, heap check before socket.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. DM Second Launch – StoreProhibited 0x8208775e
|
||||||
|
|
||||||
|
**Log:** `..._17-37-00_s015.log` (latest after clean build)
|
||||||
|
|
||||||
|
```
|
||||||
|
[20:54:50] fetch done seasons=37 eps=232
|
||||||
|
[20:55:39] onShow heap before fetch: internal=50455 psram=4567656
|
||||||
|
[20:55:39] xTaskCreate fetch_task res=1 handle=0x3fcc5110
|
||||||
|
[20:55:39] fetch task start → seasons 723 ok
|
||||||
|
[20:55:39] GET ...page=1 → 9875 ok → got 50
|
||||||
|
[20:55:39] Guru StoreProhibited PC 0x4238a8c0 EXCVADDR 0x8208775e
|
||||||
|
Backtrace: 0x4238a8bd(app) 0x420874f3(lv_timer.c:107) 0x42069754(esp_lvgl_port.c:242) 0x421fdaba(port.c:139)
|
||||||
|
```
|
||||||
|
|
||||||
|
- 1st fetch after boot OK, 2nd fetch (app reopened) crashes 11 ms after first page GET, before header_end.
|
||||||
|
- Backtrace shows crash in **LVGL timer task**, not fetch task: `lv_timer.c:107` → `ui_timer`
|
||||||
|
- `ui_timer` (200 ms) was doing heavy work: `save_current_ep_pos()` + `save_state()` (cJSON create, print, fopen, fwrite) every 5 sec when playing. Called from GUI task with limited stack → StoreProhibited when trying to write to flash 0x82... (string literal in flash?).
|
||||||
|
- Also `G` global is 240 KB (400*492) BSS in DRAM, plus `saved_pos` 40 KB = 280 KB, leaving free heap 50 KB → after first fetch cJSON 10 KB nodes, heap fragments to 3-7 KB → `MemoryChecker: Internal memory low: 3607/7279/7435`
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- `ui_timer`: early return if `G.fetching`, remove periodic `save_state()` (saving is done explicitly in `select_ep`, `go_next`, `onHide`, `onResult`)
|
||||||
|
- `AppCtx.MAX_EPS` 400→250, `title` 128→64, `audio_url` 256→128, `MP3_BUF` 16384→4096 → size 240 KB → ~100 KB
|
||||||
|
- `fetch_task` stack 16384→12288, `onShow` heap log, `xTaskCreate` result log
|
||||||
|
- Pagination 100/page → 50/page (now 25/page) with 300-400 ms delay between pages to let heap recover, `fields=season_num` and `fields=title,slug,season_num,episode_num,audio_url` to cut JSON 91 KB → 42 KB → 9 KB/page
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. DL Season Button – IntegerDivideByZero in Bundle
|
||||||
|
|
||||||
|
**Log:** `cu.usbmodem1101_16-57-59_s014.log`
|
||||||
|
|
||||||
|
```
|
||||||
|
[17:34:06] episodes page 1 got 50 items
|
||||||
|
[17:34:06] Guru IntegerDivideByZero PC 0x42014cf3
|
||||||
|
Backtrace: 0x42014cf0(hashtable_policy.h:534) 0x42014dac(unordered_map.h:988) 0x42014345(tt_bundle.cpp:46 putInt32) 0x4237a4d3(app)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Pressing **DL Season** during fetch → `G.ep_cnt` only 50 (partial), `G.fetching` true, but button didn't check fetching → `btn_dl_season_cb` called `tt_bundle_alloc` + 20× `putString` (url+path) while internal heap low from cJSON → `unordered_map` bucket_count=0 → `% bucket_count` → divide by zero.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- `btn_seasons_cb` and `btn_dl_season_cb` now check `if(G.fetching) return;`
|
||||||
|
- `btn_dl_season_cb`: `missing_idx[50]` → `[10]` to avoid LVGL stack overflow, heap check `>15 KB` before alloc, and per-put heap check `>8 KB` else abort with "Low memory, try 1 by 1"
|
||||||
|
- Also filters `strlen(audio_url)>=8`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Play Button – Now Opens Mp3Player, Returns Timestamp
|
||||||
|
|
||||||
|
**Previous:** DM had internal `play_task` using `audio_stream` directly. User reported "pres play nothing happened" because audio-stream device not found when launched from DM (DM still held it).
|
||||||
|
|
||||||
|
**Fix in `player.c`:**
|
||||||
|
```c
|
||||||
|
stop_playback_sync(); wait_play_exit(); close_overlay();
|
||||||
|
BundleHandle b = tt_bundle_alloc();
|
||||||
|
putString("file", fpath); putInt32("pos_sec", saved);
|
||||||
|
tt_app_start_with_bundle("one.tactility.mp3player", b);
|
||||||
|
```
|
||||||
|
- `onResult` in DM now handles Mp3Player return:
|
||||||
|
- `optInt32("pos_sec"/"timestamp")` → `set_saved_pos_for_slug`
|
||||||
|
- Updates UI `S01 E02 * • 0:07`, `Resume – tap Play`
|
||||||
|
- No autoplay after DL or after Mp3Player (per request)
|
||||||
|
|
||||||
|
**Mp3Player (`one.tactility.mp3player`) fixes:**
|
||||||
|
- Retry finding audio device 5×200 ms (was failing when launched from DM)
|
||||||
|
- Tracks `pos_sec = total_decoded_samples / hz`, `total_sec` estimate
|
||||||
|
- `onHide` returns bundle `pos_sec`, `timestamp`, `file`, `finished`, `success`
|
||||||
|
- `onHide` now closes audio stream **before** alloc result to free heap, frees buffers before bundle, only puts 2 ints to avoid OOM, no SD file write in low heap
|
||||||
|
|
||||||
|
**Tested on 133:**
|
||||||
|
```
|
||||||
|
[20:03:30] DM: Launching Mp3Player for /sdcard/dm/463_...mp3 pos 4
|
||||||
|
[20:03:31] Mp3Player: Found audio-stream device 0x3fca480c
|
||||||
|
[20:03:31] Starting MP3 playback size 4329701 resume=4
|
||||||
|
[20:03:31] Audio stream opened 16000 Hz, 1 ch
|
||||||
|
[20:03:56] onHide start pos_sec=15
|
||||||
|
[20:03:59] Set result bundle pos_sec=15
|
||||||
|
[20:03:59] onHide returning pos_sec=15 total_sec=269
|
||||||
|
[20:03:59] DM: onResult result=1 pending_idx=0 cur_idx=0 → saved pos 15
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. SdDownloader – Now Batch + Headless (per your latest spec)
|
||||||
|
|
||||||
|
**Manifest:** `one.tactility.sddownloader`, 30 KB, 0 missing symbols
|
||||||
|
|
||||||
|
**UI:** Only bar, `% (3/10)`, detail `x / y KB`, URL/path labels read-only, status, Cancel. No input fields.
|
||||||
|
|
||||||
|
**Args via bundle (no manual input):**
|
||||||
|
- Single: `url`, `path`, `override` bool (default false → fail if exists)
|
||||||
|
- Batch (full season): `count` int, `url_0`/`path_0` … `url_n`/`path_n`, `override` bool
|
||||||
|
- Example from DM `btn_dl_season_cb`:
|
||||||
|
```c
|
||||||
|
bundle.putInt32("count", 7);
|
||||||
|
putString("url_0", "http://.../S02E01.mp3"); putString("path_0", "/sdcard/dm/...");
|
||||||
|
...
|
||||||
|
tt_app_start_with_bundle("one.tactility.sddownloader", b);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Returns bool:**
|
||||||
|
```c
|
||||||
|
bundle.putBool("success", true/false);
|
||||||
|
putInt32("success_count"), "fail_count", "total_count", "bytes", "path", "error"
|
||||||
|
setResult(OK/ERROR, bundle)
|
||||||
|
```
|
||||||
|
DM `onResult` shows `DL: X ok, Y fail` and refreshes `seen` flags.
|
||||||
|
|
||||||
|
**Robustness:**
|
||||||
|
- 8 KB stack (was 16 KB), 4 KB PSRAM buf, 15 s timeout, 5 retries
|
||||||
|
- `ensure_parent_dir` mkdir -p without `strtok` (not exported)
|
||||||
|
- `stat` check for exists → fail if not override
|
||||||
|
- No `tt_app_stop()` from download task (caused deadlock + gui stack overflow for S01E01 empty URL)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Builds on 133
|
||||||
|
|
||||||
|
- `DiscoveryMountain.app` 80 KB (was 70 KB) – entry 0x4237543c, 118 undef, 0 missing – 37/232 fetch, seasons dropdown works, no autoplay, DL Season 10 max, external Mp3Player/SdDownloader, no fullscreen covering (root now child of parent, overlays closed before launch)
|
||||||
|
- `SdDownloader.app` 30 KB – 74 undef, 0 missing – batch 10, file-exists check
|
||||||
|
- `Mp3Player.app` 40 KB – 0 missing after adding `esp_http_client` to REQUIRES – returns timestamp
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Still Open / Next
|
||||||
|
|
||||||
|
- Second fetch after app restart still StoreProhibited at 0x4238a8c0 (PC in app) during first page GET – likely `cJSON_Parse` + `has_slug` stat + low heap after first fetch. Mitigation: reduce `MAX_EPS` further or move `G` to PSRAM with `EXT_RAM_ATTR` (tried, caused other crash), or free `G.eps` between fetches.
|
||||||
|
- S01E01 dummy fallback (empty URL) now shows "No URL – fetch failed?" instead of crash, but ideally should never appear if fetch succeeds. Ensure PB at `192.168.68.110:8095` is reachable (seen `connect fail` when WiFi bcn_timeout).
|
||||||
|
- Mp3Player close takes 2.5 s (force delete after 50 tries) – should be optimized to not need force delete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reproduction Steps for S01E04 Crash (you reported)
|
||||||
|
|
||||||
|
1. Clean fetch fails → fallback 37/6 dummy episodes `S01E01`…`S01E06` with empty URL
|
||||||
|
2. Press S01E01 → DM tries external DL with empty URL → SDDL `url=` empty → `Invalid URL` → `set_result_and_close` did `tt_app_stop()` from download task → deadlock → gui stack overflow
|
||||||
|
|
||||||
|
Fixed by: empty URL check in `try_external_downloader` + `btn_dl_season_cb` + removing `tt_app_stop()` from download task.
|
||||||
|
|
||||||
|
For real S01E04 (34 MB, `460_...`), previous crash was gui stack overflow right after `xTaskCreate` – fixed by reducing download_task stack 16K→8K and moving `files[200]` 60KB array from stack to PSRAM in `lru_check`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
- `Apps/DiscoveryMountain/main/Source/app_context.c` – added `pending_dl_idx`, `root`
|
||||||
|
- `app_context.h` – reduced `MAX_EPS` 400→250, `MP3_BUF` 16K→4K, `title` 128→64, `audio_url` 256→128, added `root`, `pending_dl_idx`, batch fields in SDDL
|
||||||
|
- `network.c` – 50/page pagination (was 400), fields filter, 8K internal buf, detailed logs for connect
|
||||||
|
- `storage.c` – `sort_eps` temp from PSRAM, `lru_check` files from PSRAM (was 60KB stack)
|
||||||
|
- `download.c` – stubbed to only overlays (internal DL removed)
|
||||||
|
- `player.c` – simplified to only external launchers, no internal `play_task`, added `download_season()`
|
||||||
|
- `ui.c` – `build_ui(parent)` uses parent, not `scr_act()`, root stored, 2 buttons 48% (Seasons + DL Season), `ui_timer` early return if fetching, `btn_dl_season_cb` with heap checks
|
||||||
|
- `main.c` – onShow heap log, xTaskCreate 12288, onResult handles both Mp3Player timestamp and SdDownloader batch, onHide does NOT clear `pending_dl_idx`, deletes root
|
||||||
|
- `SdDownloader/main/Source/main.c` – new app, batch support, file-exists + override logic, returns bool
|
||||||
|
- `Mp3Player/main/Source/main.c` – returns `pos_sec`/`timestamp`, retry audio device, auto-finds test file if no bundle
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to Test Full Season Download
|
||||||
|
|
||||||
|
1. Open DM → wait `fetch done 37/232`
|
||||||
|
2. Tap `Seasons` → S01 → tap `DL Season`
|
||||||
|
3. Should show `Starting DL 6 eps S01` → launches SDDL → bar shows `3/6: 45%` etc.
|
||||||
|
4. On finish, DM shows `DL: 6 ok, 0 fail` and refreshes `*` markers
|
||||||
|
5. Tap episode with `*` → Play → launches Mp3Player → plays from saved pos → close → DM shows `Resume • 0:12`
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
|
||||||
|
set(CJSON_SOURCE "$ENV{IDF_PATH}/components/json/cJSON/cJSON.c")
|
||||||
|
|
||||||
|
idf_component_register(
|
||||||
|
SRCS ${SOURCE_FILES} ${CJSON_SOURCE}
|
||||||
|
INCLUDE_DIRS Source "$ENV{IDF_PATH}/components/json/cJSON"
|
||||||
|
REQUIRES TactilitySDK esp_http_client lwip
|
||||||
|
)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
#include "app_context.h"
|
||||||
|
|
||||||
|
AppCtx G = {0};
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
#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 CACHE_PATH "/sdcard/apps/one.tactility.discoverymountain/userdata/cache.json"
|
||||||
|
#define MAX_SEASONS 40
|
||||||
|
#define MAX_EPS 250
|
||||||
|
#define MP3_BUF 4096
|
||||||
|
|
||||||
|
typedef struct { int num; } SeasonInfo;
|
||||||
|
typedef struct {
|
||||||
|
char title[64];
|
||||||
|
char slug[96];
|
||||||
|
int season;
|
||||||
|
int ep_num;
|
||||||
|
char audio_url[128];
|
||||||
|
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 *root;
|
||||||
|
lv_obj_t *lbl_title, *lbl_meta, *bar, *lbl_time, *lbl_status, *btn_play;
|
||||||
|
lv_obj_t *lbl_season;
|
||||||
|
lv_obj_t *ep_list_cont;
|
||||||
|
lv_obj_t *btn_seasons;
|
||||||
|
lv_obj_t *btn_dl_missing;
|
||||||
|
lv_obj_t *overlay, *dropdown;
|
||||||
|
lv_obj_t *season_list_cont;
|
||||||
|
lv_timer_t *ui_timer_handle;
|
||||||
|
// 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;
|
||||||
|
volatile int dl_total;
|
||||||
|
int dl_expected;
|
||||||
|
int dl_last_ui_update;
|
||||||
|
volatile bool dl_cancel_req;
|
||||||
|
volatile bool fetch_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;
|
||||||
|
|
||||||
|
// External downloader integration (SdDownloader)
|
||||||
|
int pending_dl_idx;
|
||||||
|
} AppCtx;
|
||||||
|
|
||||||
|
extern AppCtx G;
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
#include "download.h"
|
||||||
|
#include "storage.h"
|
||||||
|
#include <tt_lvgl.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include "esp_log.h"
|
||||||
|
|
||||||
|
// Simplified: internal download removed, we use external SdDownloader app
|
||||||
|
// Keep overlay functions as stubs for compatibility, but they do minimal
|
||||||
|
|
||||||
|
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){
|
||||||
|
// No longer used - external downloader has its own UI
|
||||||
|
// Keep as stub that shows simple status
|
||||||
|
(void)ep_title; (void)slug; (void)season; (void)ep_num;
|
||||||
|
G.downloading=true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void update_dl_overlay_ui(){
|
||||||
|
// No internal download progress anymore
|
||||||
|
}
|
||||||
|
|
||||||
|
bool dl_ep_raw(EpInfo* ep){
|
||||||
|
(void)ep;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool dl_ep(EpInfo* ep){
|
||||||
|
(void)ep;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void dl_task(void* arg){
|
||||||
|
(void)arg;
|
||||||
|
G.downloading=false;
|
||||||
|
G.dl_handle=NULL;
|
||||||
|
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,383 @@
|
|||||||
|
#include <tt_app.h>
|
||||||
|
#include <tt_lvgl.h>
|
||||||
|
#include <tt_bundle.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"
|
||||||
|
#include "esp_heap_caps.h"
|
||||||
|
|
||||||
|
static void fetch_task_fn(void* arg){
|
||||||
|
(void)arg;
|
||||||
|
G.fetching=true;
|
||||||
|
G.fetch_cancel_req=false;
|
||||||
|
ESP_LOGI(TAG,"fetch task start");
|
||||||
|
|
||||||
|
// Try load cached seasons/episodes – instant offline support
|
||||||
|
bool cache_loaded = load_cache();
|
||||||
|
if(cache_loaded){
|
||||||
|
ESP_LOGI(TAG,"Cache loaded: %d seasons, %d eps", G.season_cnt, G.ep_cnt);
|
||||||
|
// Restore cur idx from last_slug
|
||||||
|
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 && G.ep_cnt>0){
|
||||||
|
s1=0;
|
||||||
|
G.cur_season=G.eps[0].season;
|
||||||
|
}
|
||||||
|
G.cur_ep_idx=s1;
|
||||||
|
}
|
||||||
|
// Show cached data immediately
|
||||||
|
G.fetching=false;
|
||||||
|
ui_rebuild_episode_list();
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
if(G.lbl_status) lv_label_set_text(G.lbl_status,"Loaded from cache, refreshing...");
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
G.fetching=true;
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(200));
|
||||||
|
} else {
|
||||||
|
ESP_LOGI(TAG,"No cache found, will fetch from network");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network fetch – updates if possible, otherwise keeps cache
|
||||||
|
bool fetch_ok = fetch_data();
|
||||||
|
|
||||||
|
if(G.fetch_cancel_req){
|
||||||
|
ESP_LOGI(TAG,"fetch task canceled, exiting");
|
||||||
|
G.fetching=false;
|
||||||
|
G.fetch_handle=NULL;
|
||||||
|
vTaskDelete(NULL);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(fetch_ok){
|
||||||
|
ESP_LOGI(TAG,"Network fetch ok: %d seasons, %d eps – saving cache", G.season_cnt, G.ep_cnt);
|
||||||
|
save_cache();
|
||||||
|
} else {
|
||||||
|
if(cache_loaded){
|
||||||
|
ESP_LOGW(TAG,"Network fetch failed, restoring cache");
|
||||||
|
// fetch_data overwrote with fallback, restore cache
|
||||||
|
load_cache();
|
||||||
|
} else {
|
||||||
|
ESP_LOGW(TAG,"Network fetch failed and no cache – using fallback");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ESP_LOGI(TAG,"fetch done seasons=%d eps=%d cache=%d fetch_ok=%d",G.season_cnt,G.ep_cnt,cache_loaded,fetch_ok);
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(300));
|
||||||
|
|
||||||
|
// Resolve current episode index – preserve user season selection if cache was already shown
|
||||||
|
if(cache_loaded){
|
||||||
|
// User may have changed season while network fetch was in progress – keep their selection if it still exists
|
||||||
|
int cur_s = G.cur_season;
|
||||||
|
bool season_exists=false;
|
||||||
|
for(int i=0;i<G.season_cnt;i++) if(G.seasons[i].num==cur_s) { season_exists=true; break; }
|
||||||
|
if(!season_exists){
|
||||||
|
// Fallback to last_slug or first season
|
||||||
|
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; season_exists=true; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(season_exists){
|
||||||
|
// Keep cur_season, find best idx within it
|
||||||
|
int best=-1;
|
||||||
|
if(strlen(G.last_slug)>0){
|
||||||
|
for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==cur_s && strcmp(G.eps[i].slug,G.last_slug)==0){ best=i; break; }
|
||||||
|
}
|
||||||
|
if(best==-1){
|
||||||
|
for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==cur_s){ best=i; break; }
|
||||||
|
}
|
||||||
|
if(best!=-1) G.cur_ep_idx=best;
|
||||||
|
} else {
|
||||||
|
// Season no longer exists, fallback to first
|
||||||
|
if(G.ep_cnt>0){
|
||||||
|
G.cur_ep_idx=0;
|
||||||
|
G.cur_season=G.eps[0].season;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// First launch – use last_slug to restore position
|
||||||
|
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 && G.ep_cnt>0){
|
||||||
|
s1=0;
|
||||||
|
G.cur_season=G.eps[0].season;
|
||||||
|
}
|
||||||
|
G.cur_ep_idx=s1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
G.fetching=false;
|
||||||
|
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
if(G.fetch_cancel_req){
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
G.fetch_handle=NULL;
|
||||||
|
vTaskDelete(NULL);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
|
||||||
|
ui_rebuild_episode_list();
|
||||||
|
|
||||||
|
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(saved>0) G.pos_sec=saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
if(G.lbl_status){
|
||||||
|
if(G.ep_cnt>0){
|
||||||
|
if(fetch_ok) lv_label_set_text(G.lbl_status, "Select episode to play / download");
|
||||||
|
else if(cache_loaded) lv_label_set_text(G.lbl_status, "Offline, using cached data");
|
||||||
|
else lv_label_set_text(G.lbl_status,"No network – limited data");
|
||||||
|
} else {
|
||||||
|
lv_label_set_text(G.lbl_status,"No data – check network");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
|
||||||
|
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.pending_dl_idx=-1;
|
||||||
|
G.volume=80;
|
||||||
|
G.last_pct=-1;
|
||||||
|
G.dl_last_pct=-1;
|
||||||
|
G.dl_expected=-1;
|
||||||
|
G.fetch_cancel_req=false;
|
||||||
|
G.dl_cancel_req=false;
|
||||||
|
ensure_dir();
|
||||||
|
load_state();
|
||||||
|
build_ui(parent);
|
||||||
|
size_t free_internal = heap_caps_get_free_size(MALLOC_CAP_INTERNAL);
|
||||||
|
size_t free_psram = heap_caps_get_free_size(MALLOC_CAP_SPIRAM);
|
||||||
|
ESP_LOGI(TAG, "onShow heap before fetch task: internal=%d psram=%d", free_internal, free_psram);
|
||||||
|
BaseType_t res = xTaskCreate(fetch_task_fn,"dm_fetch",12288,NULL,5,&G.fetch_handle);
|
||||||
|
ESP_LOGI(TAG, "xTaskCreate fetch_task res=%d handle=%p", res, G.fetch_handle);
|
||||||
|
if (res != pdPASS) {
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
if (G.lbl_status) lv_label_set_text(G.lbl_status, "Failed to create fetch task");
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void onResult(AppHandle app, void* data, AppLaunchId launchId, AppResult result, BundleHandle resultData){
|
||||||
|
(void)app; (void)data; (void)launchId;
|
||||||
|
ESP_LOGI(TAG, "onResult: result=%d pending_idx=%d cur_idx=%d", result, G.pending_dl_idx, G.cur_ep_idx);
|
||||||
|
|
||||||
|
// Check if result contains timestamp from Mp3Player
|
||||||
|
if (resultData) {
|
||||||
|
int32_t pos = 0;
|
||||||
|
bool has_pos = false;
|
||||||
|
if (tt_bundle_opt_int32(resultData, "pos_sec", &pos) ||
|
||||||
|
tt_bundle_opt_int32(resultData, "timestamp", &pos) ||
|
||||||
|
tt_bundle_opt_int32(resultData, "position", &pos)) {
|
||||||
|
has_pos = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
char file_path[512]={0};
|
||||||
|
bool has_file = tt_bundle_opt_string(resultData, "file", file_path, sizeof(file_path)) ||
|
||||||
|
tt_bundle_opt_string(resultData, "path", file_path, sizeof(file_path));
|
||||||
|
|
||||||
|
if (has_pos) {
|
||||||
|
ESP_LOGI(TAG, "Mp3Player returned pos %d sec for file %s", pos, has_file?file_path:"unknown");
|
||||||
|
int target_idx = -1;
|
||||||
|
if (has_file && strlen(file_path)>0) {
|
||||||
|
for(int i=0;i<G.ep_cnt;i++){
|
||||||
|
char ep_path[300];
|
||||||
|
make_sd_path(ep_path, sizeof(ep_path), G.eps[i].slug);
|
||||||
|
if (strcmp(ep_path, file_path)==0) { target_idx = i; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (target_idx<0 && G.pending_dl_idx>=0 && G.pending_dl_idx < G.ep_cnt) target_idx = G.pending_dl_idx;
|
||||||
|
if (target_idx<0 && G.cur_ep_idx>=0) target_idx = G.cur_ep_idx;
|
||||||
|
|
||||||
|
if (target_idx>=0) {
|
||||||
|
bool finished = false;
|
||||||
|
tt_bundle_opt_bool(resultData, "finished", &finished);
|
||||||
|
int32_t total_sec = 0;
|
||||||
|
tt_bundle_opt_int32(resultData, "total_sec", &total_sec);
|
||||||
|
if (finished || (total_sec>0 && pos >= total_sec-10)) {
|
||||||
|
set_saved_pos_for_slug(G.eps[target_idx].slug, 0);
|
||||||
|
G.pos_sec = 0;
|
||||||
|
ESP_LOGI(TAG, "Episode %s finished, clearing pos", G.eps[target_idx].slug);
|
||||||
|
} else {
|
||||||
|
set_saved_pos_for_slug(G.eps[target_idx].slug, pos);
|
||||||
|
G.pos_sec = pos;
|
||||||
|
ESP_LOGI(TAG, "Saved pos %d for %s", pos, G.eps[target_idx].slug);
|
||||||
|
}
|
||||||
|
save_state();
|
||||||
|
ui_rebuild_episode_list();
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
if (G.lbl_status) {
|
||||||
|
if (pos>5) lv_label_set_text(G.lbl_status,"Progress saved – tap episode to resume");
|
||||||
|
else lv_label_set_text(G.lbl_status,"Select episode to play / download");
|
||||||
|
}
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
}
|
||||||
|
G.pending_dl_idx = -1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SdDownloader batch result handling
|
||||||
|
if (G.pending_dl_idx <0 || G.pending_dl_idx >= G.ep_cnt) {
|
||||||
|
if (resultData) {
|
||||||
|
int32_t succ=0, fail=0, total=0;
|
||||||
|
if (tt_bundle_opt_int32(resultData, "success_count", &succ) ||
|
||||||
|
tt_bundle_opt_int32(resultData, "total_count", &total)) {
|
||||||
|
tt_bundle_opt_int32(resultData, "fail_count", &fail);
|
||||||
|
ESP_LOGI(TAG, "Batch DL result: success %ld fail %ld total %ld", (long)succ, (long)fail, (long)total);
|
||||||
|
for(int i=0;i<G.ep_cnt;i++) G.eps[i].seen = has_slug(G.eps[i].slug);
|
||||||
|
save_state();
|
||||||
|
ui_rebuild_episode_list();
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
if (G.lbl_status) {
|
||||||
|
char msg[64];
|
||||||
|
snprintf(msg,sizeof(msg),"DL: %ld ok, %ld fail", (long)succ, (long)fail);
|
||||||
|
lv_label_set_text(G.lbl_status, msg);
|
||||||
|
}
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
G.pending_dl_idx = -1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
G.pending_dl_idx = -1;
|
||||||
|
// Still refresh list in case files appeared
|
||||||
|
for(int i=0;i<G.ep_cnt;i++) G.eps[i].seen = has_slug(G.eps[i].slug);
|
||||||
|
ui_rebuild_episode_list();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single download result
|
||||||
|
bool success = false;
|
||||||
|
if (resultData) {
|
||||||
|
tt_bundle_opt_bool(resultData, "success", &success);
|
||||||
|
}
|
||||||
|
if (result == APP_RESULT_OK) {
|
||||||
|
if (resultData) {
|
||||||
|
bool b=false;
|
||||||
|
if (tt_bundle_opt_bool(resultData, "success", &b)) success = b;
|
||||||
|
else success = true;
|
||||||
|
} else {
|
||||||
|
success = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int idx = G.pending_dl_idx;
|
||||||
|
G.pending_dl_idx = -1;
|
||||||
|
|
||||||
|
for(int i=0;i<G.ep_cnt;i++) G.eps[i].seen = has_slug(G.eps[i].slug);
|
||||||
|
save_state();
|
||||||
|
ui_rebuild_episode_list();
|
||||||
|
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
if (G.lbl_status) {
|
||||||
|
if (success) lv_label_set_text(G.lbl_status, "DL done – tap to play");
|
||||||
|
else lv_label_set_text(G.lbl_status, "DL failed – tap to retry");
|
||||||
|
}
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
|
||||||
|
if(success && idx>=0){
|
||||||
|
ESP_LOGI(TAG, "Single DL success idx %d", idx);
|
||||||
|
} else {
|
||||||
|
ESP_LOGW(TAG, "External DL failed for idx %d", idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void onHide(AppHandle app, void* data){
|
||||||
|
(void)app; (void)data;
|
||||||
|
save_current_ep_pos();
|
||||||
|
G.fetch_cancel_req=true;
|
||||||
|
G.dl_cancel_req=true;
|
||||||
|
|
||||||
|
if(G.fetch_handle){
|
||||||
|
ESP_LOGI(TAG,"onHide: waiting fetch to exit");
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
for(int i=0;i<20 && G.fetch_handle!=NULL; i++){
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(100));
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
}
|
||||||
|
if(G.fetch_handle){
|
||||||
|
ESP_LOGW(TAG,"onHide: force deleting fetch task (leak risk)");
|
||||||
|
vTaskDelete(G.fetch_handle);
|
||||||
|
G.fetch_handle=NULL;
|
||||||
|
}
|
||||||
|
G.fetching=false;
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
}
|
||||||
|
|
||||||
|
if(G.dl_handle){
|
||||||
|
ESP_LOGI(TAG,"onHide: waiting dl to exit");
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
for(int i=0;i<30 && G.dl_handle!=NULL; i++){
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(100));
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
}
|
||||||
|
if(G.dl_handle){
|
||||||
|
ESP_LOGW(TAG,"onHide: force deleting dl task after timeout");
|
||||||
|
vTaskDelete(G.dl_handle);
|
||||||
|
G.dl_handle=NULL;
|
||||||
|
G.downloading=false;
|
||||||
|
if(G.dl_overlay) hide_dl_overlay();
|
||||||
|
}
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
}
|
||||||
|
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
wait_play_exit();
|
||||||
|
ui_deinit();
|
||||||
|
close_overlay();
|
||||||
|
if(G.dl_overlay) hide_dl_overlay();
|
||||||
|
if(G.root){
|
||||||
|
lv_obj_delete(G.root);
|
||||||
|
G.root = NULL;
|
||||||
|
G.lbl_title = NULL;
|
||||||
|
G.lbl_meta = NULL;
|
||||||
|
G.lbl_season = NULL;
|
||||||
|
G.ep_list_cont = NULL;
|
||||||
|
G.lbl_time = NULL;
|
||||||
|
G.lbl_status = NULL;
|
||||||
|
G.btn_play = NULL;
|
||||||
|
G.btn_seasons = NULL;
|
||||||
|
G.btn_dl_missing = NULL;
|
||||||
|
G.season_list_cont = NULL;
|
||||||
|
G.bar = NULL;
|
||||||
|
}
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
save_state();
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char* argv[]){
|
||||||
|
(void)argc; (void)argv;
|
||||||
|
tt_app_register((AppRegistration){ .onShow=onShow, .onHide=onHide, .onResult=onResult });
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,330 @@
|
|||||||
|
#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 "esp_heap_caps.h"
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
uint16_t my_htons(uint16_t v){
|
||||||
|
// Proper byte swap with masking to avoid overflow
|
||||||
|
return (uint16_t)(((v & 0x00FF) << 8) | ((v & 0xFF00) >> 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;
|
||||||
|
ESP_LOGI(TAG,"Resolving host %s",host);
|
||||||
|
if(!resolve_host(host,&ip)){ ESP_LOGE(TAG,"ip fail %s",host); return -1; }
|
||||||
|
ESP_LOGI(TAG,"Resolved %s -> ip 0x%08x", host, ip);
|
||||||
|
int fd=lwip_socket(AF_INET,SOCK_STREAM,0);
|
||||||
|
ESP_LOGI(TAG,"Socket fd=%d", fd);
|
||||||
|
if(fd<0){ ESP_LOGE(TAG,"socket fail"); return -1; }
|
||||||
|
struct sockaddr_in sa;
|
||||||
|
ESP_LOGI(TAG,"Memset sa");
|
||||||
|
memset(&sa,0,sizeof(sa));
|
||||||
|
ESP_LOGI(TAG,"Set sa len/family/port/addr");
|
||||||
|
sa.sin_len = sizeof(sa);
|
||||||
|
sa.sin_family=AF_INET;
|
||||||
|
sa.sin_port=my_htons(port);
|
||||||
|
sa.sin_addr.s_addr=ip;
|
||||||
|
ESP_LOGI(TAG,"SetSockOpt RCV");
|
||||||
|
struct timeval tv={5,0};
|
||||||
|
int opt_res = lwip_setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&tv,sizeof(tv));
|
||||||
|
ESP_LOGI(TAG,"SetSockOpt RCV res=%d", opt_res);
|
||||||
|
ESP_LOGI(TAG,"SetSockOpt SND");
|
||||||
|
opt_res = lwip_setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&tv,sizeof(tv));
|
||||||
|
ESP_LOGI(TAG,"SetSockOpt SND res=%d", opt_res);
|
||||||
|
ESP_LOGI(TAG,"Connecting to %s:%d",host,port);
|
||||||
|
int conn_res = lwip_connect(fd,(struct sockaddr*)&sa,sizeof(sa));
|
||||||
|
ESP_LOGI(TAG,"Connect res=%d", conn_res);
|
||||||
|
if(conn_res<0){ ESP_LOGE(TAG,"connect fail %s:%d",host,port); close(fd); return -1; }
|
||||||
|
ESP_LOGI(TAG,"Connected, sending request");
|
||||||
|
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; }
|
||||||
|
ESP_LOGI(TAG,"Request sent, waiting for response");
|
||||||
|
|
||||||
|
int capacity=8192; // further reduced to 8K internal to avoid PSRAM StoreProhibited crash (seen 0x8208775e)
|
||||||
|
char* buf=malloc(capacity);
|
||||||
|
if(!buf){
|
||||||
|
buf=heap_caps_malloc(capacity, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||||
|
if(!buf){ ESP_LOGE(TAG,"buf 8k 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){
|
||||||
|
// Graceful cancel support to avoid force vTaskDelete leaking PSRAM/SD resources
|
||||||
|
if(G.fetch_cancel_req){
|
||||||
|
ESP_LOGI(TAG,"http_get_raw canceled by user");
|
||||||
|
heap_caps_free(buf);
|
||||||
|
close(fd);
|
||||||
|
return -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){
|
||||||
|
if(r<0 && (errno==EAGAIN || errno==EWOULDBLOCK || errno==ETIMEDOUT)){
|
||||||
|
ESP_LOGW(TAG,"http_get_raw timeout total=%d",total);
|
||||||
|
// On timeout, if fetching canceled, abort
|
||||||
|
if(G.fetch_cancel_req){
|
||||||
|
ESP_LOGI(TAG,"http_get_raw timeout + canceled");
|
||||||
|
heap_caps_free(buf);
|
||||||
|
close(fd);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
// Continue loop – LWIP will retry until EOF
|
||||||
|
// But if we already have header and content_len satisfied, break
|
||||||
|
if(header_end && content_len>=0 && !is_chunked && total-body_start >= content_len) break;
|
||||||
|
// If no header yet and timeout, treat as failure to avoid 15s hang
|
||||||
|
if(!header_end && total==0){
|
||||||
|
ESP_LOGE(TAG,"http_get_raw timeout before header");
|
||||||
|
heap_caps_free(buf);
|
||||||
|
close(fd);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
// Otherwise continue to try again
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool fetch_data(){
|
||||||
|
char* js=NULL;
|
||||||
|
char url[320];
|
||||||
|
bool seasons_ok=false;
|
||||||
|
bool episodes_ok=false;
|
||||||
|
ESP_LOGI(TAG,"fetch seasons");
|
||||||
|
// Reduce payload via fields filter – drastically lowers cJSON malloc pressure (internal heap low warning)
|
||||||
|
snprintf(url,sizeof(url),"%s/api/collections/dm_seasons/records?perPage=100&sort=season_num&fields=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();
|
||||||
|
if(G.season_cnt>0) seasons_ok=true;
|
||||||
|
}
|
||||||
|
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 paginated to reduce internal heap (prev low 7KB)");
|
||||||
|
G.ep_cnt=0;
|
||||||
|
for(int page=1; page<=10; page++){
|
||||||
|
if(G.fetch_cancel_req) break;
|
||||||
|
if(G.ep_cnt>=MAX_EPS) break;
|
||||||
|
snprintf(url,sizeof(url),"%s/api/collections/dm_episodes/records?perPage=25&page=%d&sort=season_num,episode_num&fields=title,slug,season_num,episode_num,audio_url",PB,page);
|
||||||
|
len=http_get_raw(url,&js);
|
||||||
|
if(len<=0 || !js){
|
||||||
|
ESP_LOGW(TAG,"episodes page %d fetch failed",page);
|
||||||
|
if(js){ free(js); js=NULL; }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cJSON* root=cJSON_Parse(js);
|
||||||
|
free(js); js=NULL;
|
||||||
|
if(!root){
|
||||||
|
ESP_LOGW(TAG,"episodes page %d JSON parse fail",page);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cJSON* items=cJSON_GetObjectItem(root,"items");
|
||||||
|
if(!cJSON_IsArray(items)){
|
||||||
|
cJSON_Delete(root);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
int n=cJSON_GetArraySize(items);
|
||||||
|
if(n==0){
|
||||||
|
cJSON_Delete(root);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
ESP_LOGI(TAG,"episodes page %d got %d items (total so far %d)",page,n,G.ep_cnt);
|
||||||
|
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++;
|
||||||
|
}
|
||||||
|
episodes_ok=true;
|
||||||
|
cJSON_Delete(root);
|
||||||
|
// Yield between pages to let MemoryChecker recover
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(400));
|
||||||
|
if(n<25) break; // last page
|
||||||
|
}
|
||||||
|
// Final sort after all pages
|
||||||
|
if(G.ep_cnt>0) sort_eps();
|
||||||
|
|
||||||
|
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);
|
||||||
|
return (seasons_ok && episodes_ok);
|
||||||
|
}
|
||||||
@@ -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 – returns true if network fetch succeeded with real data
|
||||||
|
bool fetch_data(void);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
#include "player.h"
|
||||||
|
#include "storage.h"
|
||||||
|
#include "ui.h"
|
||||||
|
#include "download.h"
|
||||||
|
|
||||||
|
#include <tt_lvgl.h>
|
||||||
|
#include <tt_app.h>
|
||||||
|
#include <tt_bundle.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include "freertos/FreeRTOS.h"
|
||||||
|
#include "freertos/task.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include "esp_heap_caps.h"
|
||||||
|
|
||||||
|
void wait_play_exit(void) {
|
||||||
|
// No internal playback anymore, nothing to wait
|
||||||
|
}
|
||||||
|
|
||||||
|
void stop_playback_sync(void) {
|
||||||
|
// No internal playback, nothing to stop
|
||||||
|
G.is_playing = false;
|
||||||
|
G.is_paused = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool try_external_downloader(int idx) {
|
||||||
|
if(idx<0||idx>=G.ep_cnt) return false;
|
||||||
|
EpInfo* ep=&G.eps[idx];
|
||||||
|
if (strlen(ep->audio_url) < 8) {
|
||||||
|
ESP_LOGW(TAG, "Skipping DL for %s: empty URL", ep->slug);
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
if (G.lbl_status) lv_label_set_text(G.lbl_status, "No URL – fetch failed?");
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
char fpath[300];
|
||||||
|
make_sd_path(fpath, sizeof(fpath), ep->slug);
|
||||||
|
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
close_overlay();
|
||||||
|
if(G.dl_overlay) hide_dl_overlay();
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
|
||||||
|
BundleHandle b = tt_bundle_alloc();
|
||||||
|
if (!b) {
|
||||||
|
ESP_LOGW(TAG, "Failed to alloc bundle for external DL");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
tt_bundle_put_string(b, "url", ep->audio_url);
|
||||||
|
tt_bundle_put_string(b, "path", fpath);
|
||||||
|
tt_bundle_put_bool(b, "override", false);
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "Starting external SdDownloader for %s -> %s", ep->slug, fpath);
|
||||||
|
G.pending_dl_idx = idx;
|
||||||
|
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
if (G.lbl_status) lv_label_set_text(G.lbl_status, "Starting downloader...");
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
|
||||||
|
tt_app_start_with_bundle("one.tactility.sddownloader", b);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool try_external_downloader_batch(int season) {
|
||||||
|
// Reverted to single-file mode – batch array caused crashes in SdDownloader
|
||||||
|
// Now just download first missing episode in season
|
||||||
|
int first_missing = -1;
|
||||||
|
for(int i=0;i<G.ep_cnt;i++){
|
||||||
|
if(G.eps[i].season == season && !G.eps[i].seen && strlen(G.eps[i].audio_url)>=8){
|
||||||
|
first_missing = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(first_missing==-1){
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
if(G.lbl_status) lv_label_set_text(G.lbl_status,"Season already cached");
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
ESP_LOGI(TAG, "DL Missing (single-file revert) – downloading first missing S%02d idx %d", season, first_missing);
|
||||||
|
return try_external_downloader(first_missing);
|
||||||
|
}
|
||||||
|
|
||||||
|
void start_idx_internal(int idx){
|
||||||
|
if(idx<0||idx>=G.ep_cnt) return;
|
||||||
|
EpInfo* ep=&G.eps[idx];
|
||||||
|
char fpath[300];
|
||||||
|
make_sd_path(fpath, sizeof(fpath), ep->slug);
|
||||||
|
|
||||||
|
if(!has_slug(ep->slug)){
|
||||||
|
ESP_LOGW(TAG, "start_idx_internal called but file missing %s", ep->slug);
|
||||||
|
start_idx(idx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int saved = 0;
|
||||||
|
if(G.cur_ep_idx!=idx){
|
||||||
|
saved = get_saved_pos_for_slug(ep->slug);
|
||||||
|
if(saved>5){
|
||||||
|
ESP_LOGI(TAG,"Restoring saved pos %d for %s", saved, ep->slug);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
saved = G.pos_sec;
|
||||||
|
}
|
||||||
|
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
close_overlay();
|
||||||
|
if(G.dl_overlay) hide_dl_overlay();
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
|
||||||
|
BundleHandle b = tt_bundle_alloc();
|
||||||
|
if(b){
|
||||||
|
tt_bundle_put_string(b, "file", fpath);
|
||||||
|
tt_bundle_put_string(b, "path", fpath);
|
||||||
|
tt_bundle_put_int32(b, "pos_sec", saved);
|
||||||
|
tt_bundle_put_int32(b, "position", saved);
|
||||||
|
tt_bundle_put_int32(b, "volume", G.volume);
|
||||||
|
ESP_LOGI(TAG, "Launching Mp3Player for %s pos %d", fpath, saved);
|
||||||
|
G.pending_dl_idx = idx;
|
||||||
|
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);
|
||||||
|
save_state();
|
||||||
|
tt_app_start_with_bundle("one.tactility.mp3player", b);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ESP_LOGW(TAG, "Failed to alloc bundle for Mp3Player");
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
if(G.lbl_status) lv_label_set_text(G.lbl_status,"Failed to launch player");
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
}
|
||||||
|
|
||||||
|
void start_idx(int idx){
|
||||||
|
if(idx<0||idx>=G.ep_cnt) return;
|
||||||
|
EpInfo* ep=&G.eps[idx];
|
||||||
|
if(!has_slug(ep->slug)){
|
||||||
|
// File not on SD, download via external downloader
|
||||||
|
if (try_external_downloader(idx)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Fallback: show message, cannot download internally anymore
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
if(G.lbl_status) lv_label_set_text(G.lbl_status,"Install SdDownloader");
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
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);
|
||||||
|
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();
|
||||||
|
int n=G.cur_ep_idx+1;
|
||||||
|
if(n>=G.ep_cnt) n=0;
|
||||||
|
select_ep(n);
|
||||||
|
// Do not auto-play next, per user request: no autoplay
|
||||||
|
}
|
||||||
|
|
||||||
|
void go_prev(){
|
||||||
|
if(G.ep_cnt==0) return;
|
||||||
|
if(G.pos_sec>5){
|
||||||
|
if(G.cur_ep_idx>=0){
|
||||||
|
set_saved_pos_for_slug(G.eps[G.cur_ep_idx].slug, 0);
|
||||||
|
save_state();
|
||||||
|
}
|
||||||
|
G.pos_sec=0;
|
||||||
|
int cur=G.cur_ep_idx;
|
||||||
|
if(cur<0) cur=0;
|
||||||
|
select_ep(cur);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
save_current_ep_pos();
|
||||||
|
save_state();
|
||||||
|
int p=G.cur_ep_idx-1;
|
||||||
|
if(p<0) p=G.ep_cnt-1;
|
||||||
|
select_ep(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called from UI for season batch download
|
||||||
|
bool download_season(int season) {
|
||||||
|
return try_external_downloader_batch(season);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "app_context.h"
|
||||||
|
#include <stdbool.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Simplified: no internal audio, only external apps
|
||||||
|
void wait_play_exit(void);
|
||||||
|
void stop_playback_sync(void);
|
||||||
|
|
||||||
|
void start_idx_internal(int idx);
|
||||||
|
void start_idx(int idx);
|
||||||
|
void select_ep(int idx);
|
||||||
|
void go_next(void);
|
||||||
|
void go_prev(void);
|
||||||
|
bool download_season(int season);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
#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>
|
||||||
|
#include "esp_heap_caps.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(){
|
||||||
|
// Use PSRAM for temp to avoid stack overflow (EpInfo ~300 bytes, was on stack)
|
||||||
|
EpInfo *t = heap_caps_malloc(sizeof(EpInfo), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||||
|
if(!t) return;
|
||||||
|
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){
|
||||||
|
*t=*a; *a=*b; *b=*t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
heap_caps_free(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool save_cache(void){
|
||||||
|
if(G.ep_cnt==0 || G.season_cnt==0) return false;
|
||||||
|
cJSON* root=cJSON_CreateObject();
|
||||||
|
if(!root) return false;
|
||||||
|
cJSON* seasons_arr=cJSON_CreateArray();
|
||||||
|
if(!seasons_arr){ cJSON_Delete(root); return false; }
|
||||||
|
for(int i=0;i<G.season_cnt && i<MAX_SEASONS;i++){
|
||||||
|
cJSON_AddItemToArray(seasons_arr, cJSON_CreateNumber(G.seasons[i].num));
|
||||||
|
}
|
||||||
|
cJSON_AddItemToObject(root,"seasons",seasons_arr);
|
||||||
|
|
||||||
|
cJSON* eps_arr=cJSON_CreateArray();
|
||||||
|
if(!eps_arr){ cJSON_Delete(root); return false; }
|
||||||
|
for(int i=0;i<G.ep_cnt && i<MAX_EPS;i++){
|
||||||
|
EpInfo* e=&G.eps[i];
|
||||||
|
if(e->slug[0]==0) continue;
|
||||||
|
cJSON* obj=cJSON_CreateObject();
|
||||||
|
if(!obj) continue;
|
||||||
|
cJSON_AddStringToObject(obj,"title", e->title);
|
||||||
|
cJSON_AddStringToObject(obj,"slug", e->slug);
|
||||||
|
cJSON_AddNumberToObject(obj,"season_num", e->season);
|
||||||
|
cJSON_AddNumberToObject(obj,"episode_num", e->ep_num);
|
||||||
|
cJSON_AddStringToObject(obj,"audio_url", e->audio_url);
|
||||||
|
cJSON_AddItemToArray(eps_arr, obj);
|
||||||
|
}
|
||||||
|
cJSON_AddItemToObject(root,"episodes",eps_arr);
|
||||||
|
|
||||||
|
char* s=cJSON_PrintUnformatted(root);
|
||||||
|
cJSON_Delete(root);
|
||||||
|
if(!s) return false;
|
||||||
|
FILE* f=fopen(CACHE_PATH,"w");
|
||||||
|
bool ok=false;
|
||||||
|
if(f){
|
||||||
|
size_t len=strlen(s);
|
||||||
|
size_t wrote=fwrite(s,1,len,f);
|
||||||
|
fclose(f);
|
||||||
|
ok = (wrote==len);
|
||||||
|
}
|
||||||
|
free(s);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool load_cache(void){
|
||||||
|
FILE* f=fopen(CACHE_PATH,"r");
|
||||||
|
if(!f) return false;
|
||||||
|
fseek(f,0,SEEK_END);
|
||||||
|
long sz=ftell(f);
|
||||||
|
fseek(f,0,SEEK_SET);
|
||||||
|
if(sz<=0 || sz>200000){ fclose(f); return false; }
|
||||||
|
// Use PSRAM for large json to avoid internal heap pressure
|
||||||
|
char* buf=heap_caps_malloc(sz+1, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||||
|
if(!buf){
|
||||||
|
buf=malloc(sz+1);
|
||||||
|
if(!buf){ fclose(f); return false; }
|
||||||
|
}
|
||||||
|
size_t got=fread(buf,1,sz,f);
|
||||||
|
buf[got]=0;
|
||||||
|
fclose(f);
|
||||||
|
cJSON* root=cJSON_Parse(buf);
|
||||||
|
heap_caps_free(buf);
|
||||||
|
if(!root) return false;
|
||||||
|
|
||||||
|
cJSON* seasons_arr=cJSON_GetObjectItem(root,"seasons");
|
||||||
|
cJSON* eps_arr=cJSON_GetObjectItem(root,"episodes");
|
||||||
|
if(!cJSON_IsArray(seasons_arr) || !cJSON_IsArray(eps_arr)){
|
||||||
|
cJSON_Delete(root);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int scnt=cJSON_GetArraySize(seasons_arr);
|
||||||
|
G.season_cnt=0;
|
||||||
|
for(int i=0;i<scnt && G.season_cnt<MAX_SEASONS;i++){
|
||||||
|
cJSON* it=cJSON_GetArrayItem(seasons_arr,i);
|
||||||
|
if(cJSON_IsNumber(it)){
|
||||||
|
G.seasons[G.season_cnt].num=it->valueint;
|
||||||
|
G.season_cnt++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int ecnt=cJSON_GetArraySize(eps_arr);
|
||||||
|
G.ep_cnt=0;
|
||||||
|
for(int i=0;i<ecnt && G.ep_cnt<MAX_EPS;i++){
|
||||||
|
cJSON* it=cJSON_GetArrayItem(eps_arr,i);
|
||||||
|
if(!cJSON_IsObject(it)) continue;
|
||||||
|
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 && cJSON_IsString(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++;
|
||||||
|
}
|
||||||
|
|
||||||
|
cJSON_Delete(root);
|
||||||
|
if(G.season_cnt>0) sort_seasons();
|
||||||
|
if(G.ep_cnt>0) sort_eps();
|
||||||
|
// Refresh seen in case SD changed
|
||||||
|
for(int i=0;i<G.ep_cnt;i++) G.eps[i].seen=has_slug(G.eps[i].slug);
|
||||||
|
return (G.season_cnt>0 && G.ep_cnt>0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void lru_check(){
|
||||||
|
typedef struct{ char path[300]; time_t mt; } FE;
|
||||||
|
// Allocate from PSRAM to avoid stack overflow (200*~304=60KB on stack would overflow 12KB task)
|
||||||
|
FE *files = heap_caps_malloc(200 * sizeof(FE), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||||
|
if(!files) return;
|
||||||
|
int fc=0;
|
||||||
|
DIR* d=opendir(DM_DIR);
|
||||||
|
if(!d){ heap_caps_free(files); 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){ heap_caps_free(files); 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);
|
||||||
|
heap_caps_free(files);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#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);
|
||||||
|
|
||||||
|
// Seasons / episodes cache (PB data, rarely changes)
|
||||||
|
bool save_cache(void);
|
||||||
|
bool load_cache(void);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,554 @@
|
|||||||
|
#include "ui.h"
|
||||||
|
#include "storage.h"
|
||||||
|
#include "player.h"
|
||||||
|
#include "download.h"
|
||||||
|
|
||||||
|
#include <tt_lvgl.h>
|
||||||
|
#include <tt_app.h>
|
||||||
|
#include <tt_bundle.h>
|
||||||
|
#include <tactility/lvgl_fonts.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include "esp_heap_caps.h"
|
||||||
|
|
||||||
|
void close_overlay(){
|
||||||
|
if(G.overlay){
|
||||||
|
lv_obj_delete(G.overlay);
|
||||||
|
G.overlay=NULL;
|
||||||
|
G.dropdown=NULL;
|
||||||
|
G.season_list_cont=NULL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void overlay_close_timer_cb(lv_timer_t* t){
|
||||||
|
close_overlay();
|
||||||
|
lv_timer_delete(t);
|
||||||
|
}
|
||||||
|
static void close_overlay_safe(void){
|
||||||
|
// Defer deletion to avoid deleting ancestor of current event target (season buttons inside overlay)
|
||||||
|
lv_timer_create(overlay_close_timer_cb, 20, NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward decls
|
||||||
|
static void ep_action_cb(lv_event_t* e);
|
||||||
|
static void season_select_cb(lv_event_t* e);
|
||||||
|
static void rebuild_episode_list_locked(void);
|
||||||
|
|
||||||
|
static void clear_container(lv_obj_t* cont){
|
||||||
|
if(!cont) return;
|
||||||
|
uint32_t child_cnt = lv_obj_get_child_cnt(cont);
|
||||||
|
while(child_cnt>0){
|
||||||
|
lv_obj_t* child = lv_obj_get_child(cont, 0);
|
||||||
|
if(child) lv_obj_delete(child);
|
||||||
|
else break;
|
||||||
|
child_cnt = lv_obj_get_child_cnt(cont);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void btn_close_cb(lv_event_t* e){
|
||||||
|
(void)e;
|
||||||
|
if(G.overlay){
|
||||||
|
close_overlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tt_app_stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void get_season_stats(int season, int* total, int* cached){
|
||||||
|
int t=0,c=0;
|
||||||
|
for(int i=0;i<G.ep_cnt;i++){
|
||||||
|
if(G.eps[i].season==season){
|
||||||
|
t++;
|
||||||
|
if(G.eps[i].seen) c++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(total) *total=t;
|
||||||
|
if(cached) *cached=c;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ui_update_status_locked(const char* msg){
|
||||||
|
if(G.lbl_status && msg) lv_label_set_text(G.lbl_status, msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ui_update_header_locked(void){
|
||||||
|
if(!G.lbl_title && !G.lbl_season) return;
|
||||||
|
if(G.fetching){
|
||||||
|
if(G.lbl_season) lv_label_set_text(G.lbl_season, "Fetching...");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(G.season_cnt==0){
|
||||||
|
if(G.lbl_season) lv_label_set_text(G.lbl_season, "No seasons");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int total=0,cached=0;
|
||||||
|
get_season_stats(G.cur_season,&total,&cached);
|
||||||
|
char buf[64];
|
||||||
|
if(total>0){
|
||||||
|
snprintf(buf,sizeof(buf),"S%02d • %d/%d on SD • %d eps", G.cur_season, cached, total, total);
|
||||||
|
}else{
|
||||||
|
snprintf(buf,sizeof(buf),"S%02d • %d eps", G.cur_season, total);
|
||||||
|
}
|
||||||
|
if(G.lbl_season) lv_label_set_text(G.lbl_season, buf);
|
||||||
|
if(G.lbl_title){
|
||||||
|
// Keep app name
|
||||||
|
// optionally update title to count
|
||||||
|
lv_label_set_text(G.lbl_title, "Discovery Mountain");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void rebuild_episode_list_locked(void){
|
||||||
|
if(!G.ep_list_cont) return;
|
||||||
|
clear_container(G.ep_list_cont);
|
||||||
|
|
||||||
|
if(G.fetching){
|
||||||
|
lv_obj_t* lbl=lv_label_create(G.ep_list_cont);
|
||||||
|
lv_label_set_text(lbl,"Fetching episodes...");
|
||||||
|
lv_obj_set_style_text_color(lbl, lv_color_hex(0xAAAAAA),0);
|
||||||
|
lv_obj_align(lbl,LV_ALIGN_TOP_MID,0,20);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(G.ep_cnt==0){
|
||||||
|
lv_obj_t* lbl=lv_label_create(G.ep_list_cont);
|
||||||
|
lv_label_set_text(lbl,"No episodes");
|
||||||
|
lv_obj_set_style_text_color(lbl, lv_color_hex(0xAAAAAA),0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count episodes for cur season
|
||||||
|
int count=0;
|
||||||
|
for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==G.cur_season) count++;
|
||||||
|
|
||||||
|
if(count==0){
|
||||||
|
lv_obj_t* lbl=lv_label_create(G.ep_list_cont);
|
||||||
|
lv_label_set_text(lbl,"No episodes in season");
|
||||||
|
lv_obj_set_style_text_color(lbl, lv_color_hex(0xAAAAAA),0);
|
||||||
|
// Also show all if fallback?
|
||||||
|
// list first few from all
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for(int i=0;i<G.ep_cnt;i++){
|
||||||
|
if(G.eps[i].season!=G.cur_season) continue;
|
||||||
|
EpInfo* ep=&G.eps[i];
|
||||||
|
|
||||||
|
// Row container – optimized for low internal heap (4 objs per ep)
|
||||||
|
lv_obj_t* row=lv_obj_create(G.ep_list_cont);
|
||||||
|
lv_obj_set_size(row, LV_PCT(100), 50);
|
||||||
|
lv_obj_set_style_bg_color(row, ep->seen ? lv_color_hex(0x1E2A22) : lv_color_hex(0x222222),0);
|
||||||
|
lv_obj_set_style_bg_opa(row, LV_OPA_COVER,0);
|
||||||
|
lv_obj_set_style_radius(row,8,0);
|
||||||
|
lv_obj_set_style_pad_all(row,6,0);
|
||||||
|
lv_obj_set_style_border_width(row,1,0);
|
||||||
|
lv_obj_set_style_border_color(row, ep->seen ? lv_color_hex(0x2E7D32) : lv_color_hex(0x333333),0);
|
||||||
|
lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW);
|
||||||
|
lv_obj_set_flex_align(row, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||||
|
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
lv_obj_set_style_pad_gap(row,6,0);
|
||||||
|
lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE);
|
||||||
|
lv_obj_add_event_cb(row, ep_action_cb, LV_EVENT_CLICKED, (void*)(intptr_t)i);
|
||||||
|
|
||||||
|
// Left single label with title (truncated) – we embed meta as second line via \n to save objects
|
||||||
|
lv_obj_t* ltitle=lv_label_create(row);
|
||||||
|
lv_obj_set_flex_grow(ltitle,1);
|
||||||
|
// Truncate title to ~26 chars for display
|
||||||
|
char short_title[30];
|
||||||
|
strncpy(short_title, ep->title, sizeof(short_title)-1);
|
||||||
|
short_title[sizeof(short_title)-1]=0;
|
||||||
|
if(strlen(ep->title) > 26){
|
||||||
|
short_title[23]='.';
|
||||||
|
short_title[24]='.';
|
||||||
|
short_title[25]='.';
|
||||||
|
short_title[26]=0;
|
||||||
|
}
|
||||||
|
char line1[64];
|
||||||
|
snprintf(line1,sizeof(line1),"E%02d: %s", ep->ep_num, short_title);
|
||||||
|
// Second line status
|
||||||
|
char line2[32];
|
||||||
|
int saved = get_saved_pos_for_slug(ep->slug);
|
||||||
|
if(ep->seen){
|
||||||
|
if(saved>5){
|
||||||
|
char tb[16]; fmt_time(tb,saved);
|
||||||
|
snprintf(line2,sizeof(line2),"On SD %s", tb);
|
||||||
|
}else{
|
||||||
|
snprintf(line2,sizeof(line2),"On SD");
|
||||||
|
}
|
||||||
|
}else{
|
||||||
|
snprintf(line2,sizeof(line2),"Not DL");
|
||||||
|
}
|
||||||
|
char tbuf[96];
|
||||||
|
snprintf(tbuf,sizeof(tbuf),"%s\n%s", line1, line2);
|
||||||
|
lv_label_set_text(ltitle, tbuf);
|
||||||
|
lv_obj_set_width(ltitle, LV_PCT(100));
|
||||||
|
lv_label_set_long_mode(ltitle, LV_LABEL_LONG_DOT);
|
||||||
|
lv_obj_set_style_text_font(ltitle, lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||||
|
lv_obj_set_style_text_color(ltitle, ep->seen ? lv_color_hex(0xE0E0E0) : lv_color_hex(0xCCCCCC),0);
|
||||||
|
|
||||||
|
// Right side: button
|
||||||
|
lv_obj_t* btn=lv_btn_create(row);
|
||||||
|
lv_obj_set_size(btn, 52, 34);
|
||||||
|
if(ep->seen){
|
||||||
|
lv_obj_set_style_bg_color(btn, lv_color_hex(0x2E7D32),0);
|
||||||
|
}else{
|
||||||
|
lv_obj_set_style_bg_color(btn, lv_color_hex(0x1E88E5),0);
|
||||||
|
}
|
||||||
|
lv_obj_set_style_radius(btn,6,0);
|
||||||
|
lv_obj_t* bl=lv_label_create(btn);
|
||||||
|
lv_label_set_text(bl, ep->seen ? LV_SYMBOL_PLAY : LV_SYMBOL_DOWNLOAD);
|
||||||
|
lv_obj_center(bl);
|
||||||
|
lv_obj_add_event_cb(btn, ep_action_cb, LV_EVENT_CLICKED, (void*)(intptr_t)i);
|
||||||
|
}
|
||||||
|
// Scroll to top after rebuild
|
||||||
|
lv_obj_scroll_to_y(G.ep_list_cont, 0, LV_ANIM_OFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ui_rebuild_episode_list(void){
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
if(G.ep_list_cont) rebuild_episode_list_locked();
|
||||||
|
ui_update_header_locked();
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ep_action_cb(lv_event_t* e){
|
||||||
|
if(lv_event_get_code(e)!=LV_EVENT_CLICKED) return;
|
||||||
|
// Retrieve idx from user data
|
||||||
|
int idx = (int)(intptr_t)lv_event_get_user_data(e);
|
||||||
|
// Fallback: try event target's user data via current target?
|
||||||
|
// lv_event_get_user_data should already give us the idx
|
||||||
|
if(idx<0 || idx>=G.ep_cnt) return;
|
||||||
|
if(G.downloading || G.fetching) return;
|
||||||
|
|
||||||
|
EpInfo* ep=&G.eps[idx];
|
||||||
|
ESP_LOGI(TAG,"ep_action idx %d S%02dE%02d seen %d", idx, ep->season, ep->ep_num, ep->seen);
|
||||||
|
|
||||||
|
if(ep->season != G.cur_season){
|
||||||
|
// Shouldn't happen but update cur season to match
|
||||||
|
G.cur_season = ep->season;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use start_idx which handles both DL and Play
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
if(G.lbl_status){
|
||||||
|
if(ep->seen) lv_label_set_text(G.lbl_status,"Starting player...");
|
||||||
|
else lv_label_set_text(G.lbl_status,"Starting downloader...");
|
||||||
|
}
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
|
||||||
|
start_idx(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void season_select_cb(lv_event_t* e){
|
||||||
|
if(lv_event_get_code(e)!=LV_EVENT_CLICKED) return;
|
||||||
|
int season_num = (int)(intptr_t)lv_event_get_user_data(e);
|
||||||
|
if(season_num<=0) return;
|
||||||
|
ESP_LOGI(TAG,"Season selected S%02d", season_num);
|
||||||
|
// Defer overlay deletion – deleting ancestor during event is unsafe
|
||||||
|
close_overlay_safe();
|
||||||
|
G.cur_season = season_num;
|
||||||
|
// Save state immediately
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
ui_update_header_locked();
|
||||||
|
rebuild_episode_list_locked();
|
||||||
|
if(G.lbl_status){
|
||||||
|
char buf[40];
|
||||||
|
snprintf(buf,sizeof(buf),"Switched to S%02d", season_num);
|
||||||
|
lv_label_set_text(G.lbl_status, buf);
|
||||||
|
}
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
save_state();
|
||||||
|
|
||||||
|
// Jump to first unseen or first in season
|
||||||
|
int first=-1, first_unseen=-1;
|
||||||
|
for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==season_num){
|
||||||
|
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){
|
||||||
|
// Optional: select ep updates title but we already rebuilt list; keep cur_ep_idx
|
||||||
|
G.cur_ep_idx = tgt;
|
||||||
|
strncpy(G.last_slug, G.eps[tgt].slug, sizeof(G.last_slug)-1);
|
||||||
|
save_state();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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_safe();
|
||||||
|
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;
|
||||||
|
G.cur_ep_idx=tgt;
|
||||||
|
strncpy(G.last_slug, G.eps[tgt].slug, sizeof(G.last_slug)-1);
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
ui_update_header_locked();
|
||||||
|
rebuild_episode_list_locked();
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
save_state();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void close_btn_cb(lv_event_t* e){
|
||||||
|
(void)e;
|
||||||
|
close_overlay_safe();
|
||||||
|
}
|
||||||
|
|
||||||
|
void btn_seasons_cb(lv_event_t* e){
|
||||||
|
(void)e;
|
||||||
|
if(G.downloading) return;
|
||||||
|
if(G.fetching) return;
|
||||||
|
if(G.overlay){ close_overlay(); return; }
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
// Create overlay centered modal – parented to root if possible to avoid lingering on app background
|
||||||
|
lv_obj_t* ovl_parent = G.root ? G.root : lv_scr_act();
|
||||||
|
G.overlay=lv_obj_create(ovl_parent);
|
||||||
|
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_set_style_border_width(G.overlay,1,0);
|
||||||
|
lv_obj_set_style_border_color(G.overlay, lv_color_hex(0x444444),0);
|
||||||
|
lv_obj_set_style_pad_all(G.overlay,8,0);
|
||||||
|
lv_obj_set_flex_flow(G.overlay, LV_FLEX_FLOW_COLUMN);
|
||||||
|
lv_obj_set_flex_align(G.overlay, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||||
|
lv_obj_set_style_pad_gap(G.overlay,6,0);
|
||||||
|
|
||||||
|
lv_obj_t* title=lv_label_create(G.overlay);
|
||||||
|
lv_label_set_text(title,"Select Season");
|
||||||
|
lv_obj_set_style_text_font(title, lvgl_get_text_font(FONT_SIZE_DEFAULT),0);
|
||||||
|
|
||||||
|
// Scrollable list container
|
||||||
|
G.season_list_cont=lv_obj_create(G.overlay);
|
||||||
|
lv_obj_set_size(G.season_list_cont, LV_PCT(100), 200);
|
||||||
|
lv_obj_set_flex_grow(G.season_list_cont,1);
|
||||||
|
lv_obj_set_style_bg_color(G.season_list_cont, lv_color_hex(0x1A1A1A),0);
|
||||||
|
lv_obj_set_style_radius(G.season_list_cont,8,0);
|
||||||
|
lv_obj_set_style_pad_all(G.season_list_cont,4,0);
|
||||||
|
lv_obj_set_flex_flow(G.season_list_cont, LV_FLEX_FLOW_COLUMN);
|
||||||
|
lv_obj_set_style_pad_gap(G.season_list_cont,4,0);
|
||||||
|
// Ensure scrollable
|
||||||
|
lv_obj_add_flag(G.season_list_cont, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
|
for(int i=0;i<G.season_cnt;i++){
|
||||||
|
int sn=G.seasons[i].num;
|
||||||
|
int total,cached;
|
||||||
|
get_season_stats(sn,&total,&cached);
|
||||||
|
lv_obj_t* btn=lv_btn_create(G.season_list_cont);
|
||||||
|
lv_obj_set_size(btn, LV_PCT(100), 38);
|
||||||
|
lv_obj_set_style_bg_color(btn, sn==G.cur_season ? lv_color_hex(0x2E7D32) : lv_color_hex(0x333333),0);
|
||||||
|
lv_obj_set_style_radius(btn,6,0);
|
||||||
|
lv_obj_set_style_pad_all(btn,6,0);
|
||||||
|
lv_obj_add_event_cb(btn, season_select_cb, LV_EVENT_CLICKED, (void*)(intptr_t)sn);
|
||||||
|
|
||||||
|
char blabel[40];
|
||||||
|
if(total>0){
|
||||||
|
if(sn==G.cur_season) snprintf(blabel,sizeof(blabel),"S%02d %d/%d " LV_SYMBOL_OK, sn, cached, total);
|
||||||
|
else snprintf(blabel,sizeof(blabel),"S%02d %d/%d", sn, cached, total);
|
||||||
|
}else{
|
||||||
|
if(sn==G.cur_season) snprintf(blabel,sizeof(blabel),"S%02d " LV_SYMBOL_OK, sn);
|
||||||
|
else snprintf(blabel,sizeof(blabel),"S%02d", sn);
|
||||||
|
}
|
||||||
|
lv_obj_t* l1=lv_label_create(btn);
|
||||||
|
lv_label_set_text(l1, blabel);
|
||||||
|
lv_obj_set_style_text_font(l1, lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||||
|
lv_obj_center(l1);
|
||||||
|
}
|
||||||
|
|
||||||
|
lv_obj_t* bc=lv_btn_create(G.overlay);
|
||||||
|
lv_obj_set_size(bc, LV_PCT(60), 36);
|
||||||
|
lv_obj_set_style_bg_color(bc, lv_color_hex(0x444444),0);
|
||||||
|
lv_obj_t* lc=lv_label_create(bc);
|
||||||
|
lv_label_set_text(lc,"Close");
|
||||||
|
lv_obj_center(lc);
|
||||||
|
lv_obj_add_event_cb(bc,close_btn_cb,LV_EVENT_CLICKED,NULL);
|
||||||
|
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
}
|
||||||
|
|
||||||
|
void btn_dl_season_cb(lv_event_t* e){
|
||||||
|
(void)e;
|
||||||
|
if(G.downloading) return;
|
||||||
|
if(G.fetching) return;
|
||||||
|
if(G.ep_cnt==0) return;
|
||||||
|
|
||||||
|
int season = G.cur_season;
|
||||||
|
ESP_LOGI(TAG, "DL Missing S%02d pressed", season);
|
||||||
|
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
close_overlay();
|
||||||
|
if(G.dl_overlay) hide_dl_overlay();
|
||||||
|
if(G.lbl_status){
|
||||||
|
char buf[40];
|
||||||
|
snprintf(buf,sizeof(buf),"Checking S%02d missing...", season);
|
||||||
|
lv_label_set_text(G.lbl_status, buf);
|
||||||
|
}
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
|
||||||
|
if (!download_season(season)) {
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
if(G.lbl_status) lv_label_set_text(G.lbl_status,"DL Season failed – no missing?");
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void build_ui(lv_obj_t* parent){
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
lv_obj_t* root = lv_obj_create(parent);
|
||||||
|
G.root = root;
|
||||||
|
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,6,0);
|
||||||
|
lv_obj_set_style_pad_gap(root,6,0);
|
||||||
|
lv_obj_set_style_border_width(root,0,0);
|
||||||
|
lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN);
|
||||||
|
lv_obj_set_flex_align(root, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
|
||||||
|
lv_obj_remove_flag(root, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
|
// Header container
|
||||||
|
lv_obj_t* header=lv_obj_create(root);
|
||||||
|
lv_obj_set_size(header, LV_PCT(100), LV_SIZE_CONTENT);
|
||||||
|
lv_obj_set_style_bg_opa(header, LV_OPA_TRANSP,0);
|
||||||
|
lv_obj_set_style_border_width(header,0,0);
|
||||||
|
lv_obj_set_style_pad_all(header,2,0);
|
||||||
|
lv_obj_set_style_pad_gap(header,6,0);
|
||||||
|
lv_obj_set_flex_flow(header, LV_FLEX_FLOW_ROW);
|
||||||
|
lv_obj_set_flex_align(header, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||||
|
lv_obj_remove_flag(header, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
|
lv_obj_t* header_left=lv_obj_create(header);
|
||||||
|
lv_obj_set_size(header_left, LV_PCT(80), LV_SIZE_CONTENT);
|
||||||
|
lv_obj_set_style_bg_opa(header_left, LV_OPA_TRANSP,0);
|
||||||
|
lv_obj_set_style_border_width(header_left,0,0);
|
||||||
|
lv_obj_set_style_pad_all(header_left,0,0);
|
||||||
|
lv_obj_set_flex_flow(header_left, LV_FLEX_FLOW_COLUMN);
|
||||||
|
lv_obj_set_style_pad_gap(header_left,2,0);
|
||||||
|
lv_obj_remove_flag(header_left, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
lv_obj_set_flex_grow(header_left,1);
|
||||||
|
|
||||||
|
G.lbl_title=lv_label_create(header_left);
|
||||||
|
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_style_text_color(G.lbl_title, lv_color_hex(0xFFFFFF),0);
|
||||||
|
|
||||||
|
G.lbl_season=lv_label_create(header_left);
|
||||||
|
lv_label_set_text(G.lbl_season,"Loading seasons...");
|
||||||
|
lv_obj_set_style_text_font(G.lbl_season, lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||||
|
lv_obj_set_style_text_color(G.lbl_season,lv_color_hex(0xAAAAAA),0);
|
||||||
|
|
||||||
|
// Close button top right
|
||||||
|
lv_obj_t* btn_close=lv_btn_create(header);
|
||||||
|
lv_obj_set_size(btn_close, 36, 32);
|
||||||
|
lv_obj_set_style_bg_color(btn_close, lv_color_hex(0x333333),0);
|
||||||
|
lv_obj_set_style_radius(btn_close,6,0);
|
||||||
|
lv_obj_t* l_close=lv_label_create(btn_close);
|
||||||
|
lv_label_set_text(l_close, LV_SYMBOL_CLOSE);
|
||||||
|
lv_obj_set_style_text_font(l_close, lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||||
|
lv_obj_center(l_close);
|
||||||
|
lv_obj_add_event_cb(btn_close, btn_close_cb, LV_EVENT_CLICKED, NULL);
|
||||||
|
|
||||||
|
// Action row: Seasons + DL Missing
|
||||||
|
lv_obj_t* row=lv_obj_create(root);
|
||||||
|
lv_obj_set_size(row,LV_PCT(100),44);
|
||||||
|
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_set_style_pad_all(row,0,0);
|
||||||
|
lv_obj_set_style_pad_gap(row,8,0);
|
||||||
|
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
|
lv_obj_t* bs=lv_btn_create(row);
|
||||||
|
lv_obj_set_size(bs,LV_PCT(48),36);
|
||||||
|
G.btn_seasons=bs;
|
||||||
|
lv_obj_set_style_bg_color(bs, lv_color_hex(0x333333),0);
|
||||||
|
lv_obj_set_style_radius(bs,6,0);
|
||||||
|
lv_obj_t* ls=lv_label_create(bs);
|
||||||
|
lv_label_set_text(ls,"Seasons");
|
||||||
|
lv_obj_set_style_text_font(ls, lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||||
|
lv_obj_center(ls);
|
||||||
|
lv_obj_add_event_cb(bs,btn_seasons_cb,LV_EVENT_CLICKED,NULL);
|
||||||
|
|
||||||
|
lv_obj_t* bd=lv_btn_create(row);
|
||||||
|
lv_obj_set_size(bd,LV_PCT(48),36);
|
||||||
|
G.btn_dl_missing=bd;
|
||||||
|
lv_obj_set_style_bg_color(bd, lv_color_hex(0x1E88E5), 0);
|
||||||
|
lv_obj_set_style_radius(bd,6,0);
|
||||||
|
lv_obj_t* ld=lv_label_create(bd);
|
||||||
|
lv_label_set_text(ld,"DL Missing");
|
||||||
|
lv_obj_set_style_text_font(ld, lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||||
|
lv_obj_center(ld);
|
||||||
|
lv_obj_add_event_cb(bd,btn_dl_season_cb,LV_EVENT_CLICKED,NULL);
|
||||||
|
|
||||||
|
// Episode list container – fills remaining space
|
||||||
|
G.ep_list_cont=lv_obj_create(root);
|
||||||
|
lv_obj_set_size(G.ep_list_cont, LV_PCT(100), LV_PCT(100));
|
||||||
|
lv_obj_set_flex_grow(G.ep_list_cont, 1);
|
||||||
|
lv_obj_set_style_bg_color(G.ep_list_cont, lv_color_hex(0x0E0E0E),0);
|
||||||
|
lv_obj_set_style_bg_opa(G.ep_list_cont, LV_OPA_COVER,0);
|
||||||
|
lv_obj_set_style_radius(G.ep_list_cont,8,0);
|
||||||
|
lv_obj_set_style_pad_all(G.ep_list_cont,4,0);
|
||||||
|
lv_obj_set_style_pad_gap(G.ep_list_cont,4,0);
|
||||||
|
lv_obj_set_flex_flow(G.ep_list_cont, LV_FLEX_FLOW_COLUMN);
|
||||||
|
lv_obj_set_flex_align(G.ep_list_cont, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
|
||||||
|
// Ensure scrollable
|
||||||
|
lv_obj_add_flag(G.ep_list_cont, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
lv_obj_set_scrollbar_mode(G.ep_list_cont, LV_SCROLLBAR_MODE_AUTO);
|
||||||
|
|
||||||
|
lv_obj_t* hint=lv_label_create(G.ep_list_cont);
|
||||||
|
lv_label_set_text(hint,"Loading episodes...");
|
||||||
|
lv_obj_set_style_text_color(hint, lv_color_hex(0x888888),0);
|
||||||
|
lv_obj_set_style_text_font(hint, lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||||
|
|
||||||
|
// Status bar at bottom
|
||||||
|
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_set_style_text_font(G.lbl_status, lvgl_get_text_font(FONT_SIZE_SMALL),0);
|
||||||
|
lv_obj_set_width(G.lbl_status, LV_PCT(100));
|
||||||
|
lv_label_set_long_mode(G.lbl_status, LV_LABEL_LONG_DOT);
|
||||||
|
|
||||||
|
// Legacy placeholders nulled
|
||||||
|
G.lbl_meta=NULL;
|
||||||
|
G.bar=NULL;
|
||||||
|
G.lbl_time=NULL;
|
||||||
|
G.btn_play=NULL;
|
||||||
|
G.dl_overlay=NULL;
|
||||||
|
G.ui_timer_handle=NULL;
|
||||||
|
|
||||||
|
G.ui_timer_handle = lv_timer_create(ui_timer,300,NULL);
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ui_deinit(void){
|
||||||
|
if(G.ui_timer_handle){
|
||||||
|
lv_timer_delete(G.ui_timer_handle);
|
||||||
|
G.ui_timer_handle=NULL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ui_timer(lv_timer_t* t){
|
||||||
|
(void)t;
|
||||||
|
// If app is closing (root null) skip
|
||||||
|
if(!G.root) return;
|
||||||
|
if(G.fetching){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(G.downloading && G.dl_overlay){
|
||||||
|
update_dl_overlay_ui();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void btn_play_cb(lv_event_t* e){ (void)e; /* legacy, not used */ }
|
||||||
|
void btn_prev_cb(lv_event_t* e){ (void)e; /* legacy */ }
|
||||||
|
void btn_next_cb(lv_event_t* e){ (void)e; /* legacy */ }
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "app_context.h"
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
void build_ui(lv_obj_t* parent);
|
||||||
|
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 btn_dl_season_cb(lv_event_t* e);
|
||||||
|
void close_overlay(void);
|
||||||
|
void dropdown_cb(lv_event_t* e);
|
||||||
|
|
||||||
|
// New library UI helpers
|
||||||
|
void ui_rebuild_episode_list(void);
|
||||||
|
void ui_update_header_locked(void);
|
||||||
|
void ui_update_status_locked(const char* msg);
|
||||||
|
void ui_deinit(void);
|
||||||
|
|
||||||
|
#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
|
||||||
@@ -2,5 +2,5 @@ file(GLOB_RECURSE SOURCE_FILES Source/*.c)
|
|||||||
|
|
||||||
idf_component_register(
|
idf_component_register(
|
||||||
SRCS ${SOURCE_FILES}
|
SRCS ${SOURCE_FILES}
|
||||||
REQUIRES TactilitySDK
|
REQUIRES TactilitySDK esp_http_client
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#include <tt_app.h>
|
#include <tt_app.h>
|
||||||
#include <tt_lvgl.h>
|
#include <tt_lvgl.h>
|
||||||
#include <tt_lvgl_toolbar.h>
|
#include <tt_lvgl_toolbar.h>
|
||||||
|
#include <tt_bundle.h>
|
||||||
|
|
||||||
#include <tactility/device.h>
|
#include <tactility/device.h>
|
||||||
#include <tactility/drivers/audio_stream.h>
|
#include <tactility/drivers/audio_stream.h>
|
||||||
@@ -9,6 +10,8 @@
|
|||||||
#include <string.h>
|
#include <string.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
#include "freertos/FreeRTOS.h"
|
#include "freertos/FreeRTOS.h"
|
||||||
#include "freertos/task.h"
|
#include "freertos/task.h"
|
||||||
@@ -32,6 +35,7 @@ typedef struct {
|
|||||||
AudioStreamHandle stream_handle;
|
AudioStreamHandle stream_handle;
|
||||||
char filepath[512];
|
char filepath[512];
|
||||||
PlaybackState state;
|
PlaybackState state;
|
||||||
|
AppHandle app_handle;
|
||||||
|
|
||||||
// UI elements
|
// UI elements
|
||||||
lv_obj_t* lbl_title;
|
lv_obj_t* lbl_title;
|
||||||
@@ -55,6 +59,11 @@ typedef struct {
|
|||||||
// Progress calculation
|
// Progress calculation
|
||||||
size_t file_size;
|
size_t file_size;
|
||||||
size_t bytes_read_total;
|
size_t bytes_read_total;
|
||||||
|
int pos_sec;
|
||||||
|
int total_sec;
|
||||||
|
long total_decoded_samples;
|
||||||
|
int start_pos_sec; // from bundle
|
||||||
|
bool finished_naturally;
|
||||||
|
|
||||||
TaskHandle_t playback_task_handle;
|
TaskHandle_t playback_task_handle;
|
||||||
} AppCtx;
|
} AppCtx;
|
||||||
@@ -115,6 +124,33 @@ static void mp3_playback_task(void* arg) {
|
|||||||
ctx->file_size = ftell(ctx->file);
|
ctx->file_size = ftell(ctx->file);
|
||||||
fseek(ctx->file, 0, SEEK_SET);
|
fseek(ctx->file, 0, SEEK_SET);
|
||||||
ctx->bytes_read_total = 0;
|
ctx->bytes_read_total = 0;
|
||||||
|
ctx->pos_sec = 0;
|
||||||
|
ctx->total_sec = 0;
|
||||||
|
ctx->total_decoded_samples = 0;
|
||||||
|
ctx->finished_naturally = false;
|
||||||
|
|
||||||
|
// Handle ID3 and resume seek (like DiscoveryMountain)
|
||||||
|
uint8_t id3hdr[10];
|
||||||
|
long id3_skip = 0;
|
||||||
|
if (fread(id3hdr,1,10,ctx->file)==10 && memcmp(id3hdr,"ID3",3)==0) {
|
||||||
|
int id3size = (id3hdr[6]&0x7F)<<21 | (id3hdr[7]&0x7F)<<14 | (id3hdr[8]&0x7F)<<7 | (id3hdr[9]&0x7F);
|
||||||
|
id3_skip = id3size + 10;
|
||||||
|
fseek(ctx->file, id3_skip, SEEK_SET);
|
||||||
|
} else {
|
||||||
|
fseek(ctx->file, 0, SEEK_SET);
|
||||||
|
}
|
||||||
|
long file_data_size = ctx->file_size - id3_skip;
|
||||||
|
if (file_data_size>0) ctx->total_sec = (int)(file_data_size / 16000); // rough estimate
|
||||||
|
|
||||||
|
if (ctx->start_pos_sec > 5) {
|
||||||
|
long seek_bytes = (long)ctx->start_pos_sec * 16000;
|
||||||
|
if (seek_bytes < file_data_size) {
|
||||||
|
fseek(ctx->file, id3_skip + seek_bytes, SEEK_SET);
|
||||||
|
ctx->total_decoded_samples = (long)ctx->start_pos_sec * 16000;
|
||||||
|
ctx->pos_sec = ctx->start_pos_sec;
|
||||||
|
ESP_LOGI(TAG, "Resuming %s from %d sec", ctx->filepath, ctx->start_pos_sec);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
mp3dec_init(&ctx->decoder);
|
mp3dec_init(&ctx->decoder);
|
||||||
ctx->buffered_bytes = 0;
|
ctx->buffered_bytes = 0;
|
||||||
@@ -123,7 +159,7 @@ static void mp3_playback_task(void* arg) {
|
|||||||
ctx->channels = 0;
|
ctx->channels = 0;
|
||||||
ctx->stream_handle = NULL;
|
ctx->stream_handle = NULL;
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Starting MP3 playback: %s (size: %d bytes)", ctx->filepath, ctx->file_size);
|
ESP_LOGI(TAG, "Starting MP3 playback: %s (size: %d bytes) resume=%d", ctx->filepath, ctx->file_size, ctx->start_pos_sec);
|
||||||
|
|
||||||
while (ctx->state != STATE_IDLE) {
|
while (ctx->state != STATE_IDLE) {
|
||||||
if (ctx->state == STATE_PAUSED) {
|
if (ctx->state == STATE_PAUSED) {
|
||||||
@@ -174,6 +210,15 @@ static void mp3_playback_task(void* arg) {
|
|||||||
memmove(ctx->input_buf, ctx->input_buf + consumed, ctx->buffered_bytes);
|
memmove(ctx->input_buf, ctx->input_buf + consumed, ctx->buffered_bytes);
|
||||||
|
|
||||||
if (samples > 0) {
|
if (samples > 0) {
|
||||||
|
ctx->total_decoded_samples += samples;
|
||||||
|
if (info.hz > 0) {
|
||||||
|
ctx->pos_sec = ctx->total_decoded_samples / info.hz;
|
||||||
|
// Update total_sec estimate if needed
|
||||||
|
if (ctx->total_sec == 0 && ctx->file_size > 0) {
|
||||||
|
// rough estimate already done, but keep
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Configure audio-stream if format changed
|
// Configure audio-stream if format changed
|
||||||
if (ctx->sample_rate != info.hz || ctx->channels != info.channels) {
|
if (ctx->sample_rate != info.hz || ctx->channels != info.channels) {
|
||||||
if (ctx->stream_handle != NULL) {
|
if (ctx->stream_handle != NULL) {
|
||||||
@@ -238,6 +283,14 @@ static void mp3_playback_task(void* arg) {
|
|||||||
|
|
||||||
fclose(ctx->file);
|
fclose(ctx->file);
|
||||||
ctx->file = NULL;
|
ctx->file = NULL;
|
||||||
|
|
||||||
|
if (ctx->pos_sec > 0 && ctx->total_sec >0 && ctx->pos_sec >= ctx->total_sec - 10) {
|
||||||
|
// Finished naturally, clear resume
|
||||||
|
ctx->pos_sec = 0;
|
||||||
|
ctx->finished_naturally = true;
|
||||||
|
} else {
|
||||||
|
ctx->finished_naturally = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Close audio stream to clean up DMA / resampler task
|
// Close audio stream to clean up DMA / resampler task
|
||||||
if (ctx->stream_handle != NULL) {
|
if (ctx->stream_handle != NULL) {
|
||||||
@@ -245,7 +298,7 @@ static void mp3_playback_task(void* arg) {
|
|||||||
ctx->stream_handle = NULL;
|
ctx->stream_handle = NULL;
|
||||||
}
|
}
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Playback task finished");
|
ESP_LOGI(TAG, "Playback task finished pos=%d total=%d naturally=%d", ctx->pos_sec, ctx->total_sec, ctx->finished_naturally);
|
||||||
|
|
||||||
tt_lvgl_lock(portMAX_DELAY);
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
ctx->state = STATE_IDLE;
|
ctx->state = STATE_IDLE;
|
||||||
@@ -286,32 +339,94 @@ static void on_stop_click(lv_event_t* e) {
|
|||||||
static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
||||||
memset(&g_ctx, 0, sizeof(g_ctx));
|
memset(&g_ctx, 0, sizeof(g_ctx));
|
||||||
g_ctx.volume = 80;
|
g_ctx.volume = 80;
|
||||||
|
g_ctx.app_handle = app;
|
||||||
|
|
||||||
// Allocate memory buffers
|
// Allocate memory buffers
|
||||||
g_ctx.input_buf = (uint8_t*)malloc(MP3_INPUT_BUFFER_SIZE);
|
g_ctx.input_buf = (uint8_t*)malloc(MP3_INPUT_BUFFER_SIZE);
|
||||||
g_ctx.pcm_buf = (mp3d_sample_t*)malloc(MINIMP3_MAX_SAMPLES_PER_FRAME * sizeof(mp3d_sample_t));
|
g_ctx.pcm_buf = (mp3d_sample_t*)malloc(MINIMP3_MAX_SAMPLES_PER_FRAME * sizeof(mp3d_sample_t));
|
||||||
|
|
||||||
// Find audio-stream device (resampling layer over ES8311 codec)
|
// Find audio-stream device with retry (seen "not found" when launched from DM)
|
||||||
g_ctx.stream_dev = device_find_by_name("audio-stream");
|
for (int tries=0; tries<5; tries++) {
|
||||||
|
struct Device* dev = device_find_by_name("audio-stream");
|
||||||
|
if (dev) { g_ctx.stream_dev = dev; break; }
|
||||||
|
dev = device_find_first_by_type(&AUDIO_STREAM_TYPE);
|
||||||
|
if (dev) { g_ctx.stream_dev = dev; break; }
|
||||||
|
ESP_LOGW(TAG, "Audio-stream not found, retry %d/5", tries);
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(200));
|
||||||
|
}
|
||||||
if (!g_ctx.stream_dev) {
|
if (!g_ctx.stream_dev) {
|
||||||
ESP_LOGE(TAG, "Audio-stream device not found!");
|
ESP_LOGE(TAG, "Audio-stream device not found after retries!");
|
||||||
|
} else {
|
||||||
|
ESP_LOGI(TAG, "Found audio-stream device %p", g_ctx.stream_dev);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse launch parameters
|
// Parse launch parameters
|
||||||
BundleHandle bundle = tt_app_get_parameters(app);
|
BundleHandle bundle = tt_app_get_parameters(app);
|
||||||
|
bool has_file_param = false;
|
||||||
if (bundle) {
|
if (bundle) {
|
||||||
char file_param[256] = {0};
|
char file_param[512] = {0};
|
||||||
if (tt_bundle_opt_string(bundle, "file", file_param, sizeof(file_param))) {
|
if (tt_bundle_opt_string(bundle, "file", file_param, sizeof(file_param)) ||
|
||||||
|
tt_bundle_opt_string(bundle, "path", file_param, sizeof(file_param)) ||
|
||||||
|
tt_bundle_opt_string(bundle, "filepath", file_param, sizeof(file_param))) {
|
||||||
// Safely resolve SD card path
|
// Safely resolve SD card path
|
||||||
if (strncmp(file_param, "/sdcard", 7) == 0) {
|
if (strncmp(file_param, "/sdcard", 7) == 0) {
|
||||||
strncpy(g_ctx.filepath, file_param, sizeof(g_ctx.filepath) - 1);
|
strncpy(g_ctx.filepath, file_param, sizeof(g_ctx.filepath) - 1);
|
||||||
|
g_ctx.filepath[sizeof(g_ctx.filepath)-1]=0;
|
||||||
} else {
|
} else {
|
||||||
if (file_param[0] == '/') {
|
if (file_param[0] == '/') {
|
||||||
snprintf(g_ctx.filepath, sizeof(g_ctx.filepath), "/sdcard%s", file_param);
|
size_t needed = 7 + strlen(file_param) + 1;
|
||||||
|
if (needed <= sizeof(g_ctx.filepath)) {
|
||||||
|
strcpy(g_ctx.filepath, "/sdcard");
|
||||||
|
strcat(g_ctx.filepath, file_param);
|
||||||
|
} else {
|
||||||
|
strncpy(g_ctx.filepath, file_param, sizeof(g_ctx.filepath)-1);
|
||||||
|
g_ctx.filepath[sizeof(g_ctx.filepath)-1]=0;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
snprintf(g_ctx.filepath, sizeof(g_ctx.filepath), "/sdcard/%s", file_param);
|
size_t needed = 8 + strlen(file_param) + 1;
|
||||||
|
if (needed <= sizeof(g_ctx.filepath)) {
|
||||||
|
strcpy(g_ctx.filepath, "/sdcard/");
|
||||||
|
strcat(g_ctx.filepath, file_param);
|
||||||
|
} else {
|
||||||
|
strncpy(g_ctx.filepath, file_param, sizeof(g_ctx.filepath)-1);
|
||||||
|
g_ctx.filepath[sizeof(g_ctx.filepath)-1]=0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
has_file_param = true;
|
||||||
|
}
|
||||||
|
int32_t pos = 0;
|
||||||
|
if (tt_bundle_opt_int32(bundle, "pos_sec", &pos) ||
|
||||||
|
tt_bundle_opt_int32(bundle, "position", &pos) ||
|
||||||
|
tt_bundle_opt_int32(bundle, "timestamp", &pos)) {
|
||||||
|
g_ctx.start_pos_sec = pos;
|
||||||
|
}
|
||||||
|
int32_t vol = 0;
|
||||||
|
if (tt_bundle_opt_int32(bundle, "volume", &vol)) {
|
||||||
|
g_ctx.volume = vol;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For direct testing from Launcher without bundle (user request: run mp3 player with episode on sd)
|
||||||
|
// Use a known existing file on SD card if no file param provided
|
||||||
|
if (!has_file_param) {
|
||||||
|
// Try to find first mp3 in /sdcard/dm/
|
||||||
|
// For quick test, use a hardcoded existing file from logs
|
||||||
|
const char *test_files[] = {
|
||||||
|
"/sdcard/dm/462_Gadgets_Escape_S01E02.mp3",
|
||||||
|
"/sdcard/dm/463_Discovering_Discovery_Mountain_S01E01.mp3",
|
||||||
|
"/sdcard/download/test2.mp3",
|
||||||
|
"/sdcard/download/test.mp3"
|
||||||
|
};
|
||||||
|
for (int i=0; i<4; i++) {
|
||||||
|
struct stat st;
|
||||||
|
if (stat(test_files[i], &st)==0 && st.st_size>1024) {
|
||||||
|
strncpy(g_ctx.filepath, test_files[i], sizeof(g_ctx.filepath)-1);
|
||||||
|
g_ctx.filepath[sizeof(g_ctx.filepath)-1]=0;
|
||||||
|
ESP_LOGI(TAG, "No file param, using test file %s", g_ctx.filepath);
|
||||||
|
has_file_param = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,21 +529,36 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static void onHideApp(AppHandle app, void* data) {
|
static void onHideApp(AppHandle app, void* data) {
|
||||||
|
(void)app; (void)data;
|
||||||
|
ESP_LOGI(TAG, "onHide start pos_sec=%d", g_ctx.pos_sec);
|
||||||
|
|
||||||
if (g_ctx.state != STATE_IDLE) {
|
if (g_ctx.state != STATE_IDLE) {
|
||||||
g_ctx.state = STATE_IDLE; // Signal task to stop
|
g_ctx.state = STATE_IDLE;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for the task to finish self-deletion to prevent resource leaks
|
int tries=0;
|
||||||
while (g_ctx.playback_task_handle != NULL) {
|
while (g_ctx.playback_task_handle != NULL && tries<50) {
|
||||||
vTaskDelay(pdMS_TO_TICKS(10));
|
vTaskDelay(pdMS_TO_TICKS(50));
|
||||||
|
tries++;
|
||||||
|
if (tries%10==0) {
|
||||||
|
ESP_LOGI(TAG, "onHide waiting for playback task %d/50", tries);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (g_ctx.playback_task_handle != NULL) {
|
||||||
// Close any open audio stream
|
ESP_LOGW(TAG, "Playback task stuck, force deleting");
|
||||||
|
vTaskDelete(g_ctx.playback_task_handle);
|
||||||
|
g_ctx.playback_task_handle = NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "Playback task stopped, closing audio stream");
|
||||||
|
|
||||||
|
// Close audio stream BEFORE allocating result bundle to free internal heap (seen crash on close)
|
||||||
if (g_ctx.stream_handle != NULL) {
|
if (g_ctx.stream_handle != NULL) {
|
||||||
audio_stream_close(g_ctx.stream_handle);
|
audio_stream_close(g_ctx.stream_handle);
|
||||||
g_ctx.stream_handle = NULL;
|
g_ctx.stream_handle = NULL;
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(100)); // let DMA free
|
||||||
}
|
}
|
||||||
|
|
||||||
if (g_ctx.input_buf) {
|
if (g_ctx.input_buf) {
|
||||||
free(g_ctx.input_buf);
|
free(g_ctx.input_buf);
|
||||||
g_ctx.input_buf = NULL;
|
g_ctx.input_buf = NULL;
|
||||||
@@ -437,6 +567,29 @@ static void onHideApp(AppHandle app, void* data) {
|
|||||||
free(g_ctx.pcm_buf);
|
free(g_ctx.pcm_buf);
|
||||||
g_ctx.pcm_buf = NULL;
|
g_ctx.pcm_buf = NULL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
size_t free_internal = heap_caps_get_free_size(MALLOC_CAP_INTERNAL);
|
||||||
|
ESP_LOGI(TAG, "Heap after free buffers: internal=%d", free_internal);
|
||||||
|
|
||||||
|
// Return timestamp result - minimal to avoid OOM and timeout
|
||||||
|
// Only allocate bundle if heap > 8KB, and only put 1-2 ints
|
||||||
|
if (free_internal > 6000) {
|
||||||
|
BundleHandle result = tt_bundle_alloc();
|
||||||
|
if (result) {
|
||||||
|
tt_bundle_put_int32(result, "pos_sec", g_ctx.pos_sec);
|
||||||
|
tt_bundle_put_int32(result, "timestamp", g_ctx.pos_sec);
|
||||||
|
tt_app_set_result(g_ctx.app_handle, APP_RESULT_OK, result);
|
||||||
|
ESP_LOGI(TAG, "Set result bundle pos_sec=%d", g_ctx.pos_sec);
|
||||||
|
} else {
|
||||||
|
ESP_LOGW(TAG, "Bundle alloc failed, returning OK without data");
|
||||||
|
tt_app_set_result(g_ctx.app_handle, APP_RESULT_OK, NULL);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ESP_LOGW(TAG, "Low heap %d, returning OK without bundle", free_internal);
|
||||||
|
tt_app_set_result(g_ctx.app_handle, APP_RESULT_OK, NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "onHide returning pos_sec=%d total_sec=%d finished=%d", g_ctx.pos_sec, g_ctx.total_sec, g_ctx.finished_naturally);
|
||||||
}
|
}
|
||||||
|
|
||||||
int main(int argc, char* argv[]) {
|
int main(int argc, char* argv[]) {
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.20)
|
||||||
|
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||||
|
if (DEFINED ENV{TACTILITY_SDK_PATH})
|
||||||
|
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
|
||||||
|
else()
|
||||||
|
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
|
||||||
|
message(WARNING "TACTILITY_SDK_PATH not set, defaulting to ${TACTILITY_SDK_PATH}")
|
||||||
|
endif()
|
||||||
|
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
|
||||||
|
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
|
||||||
|
project(SdDownloader)
|
||||||
|
tactility_project(SdDownloader)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
|
||||||
|
idf_component_register(
|
||||||
|
SRCS ${SOURCE_FILES}
|
||||||
|
INCLUDE_DIRS Source
|
||||||
|
REQUIRES TactilitySDK esp_http_client
|
||||||
|
)
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
#include <tt_app.h>
|
||||||
|
#include <tt_lvgl.h>
|
||||||
|
#include <tt_bundle.h>
|
||||||
|
#include <tactility/lvgl_fonts.h>
|
||||||
|
|
||||||
|
#include <string.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <errno.h>
|
||||||
|
|
||||||
|
#include "freertos/FreeRTOS.h"
|
||||||
|
#include "freertos/task.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include "esp_http_client.h"
|
||||||
|
#include "esp_heap_caps.h"
|
||||||
|
|
||||||
|
#define TAG "SDDL"
|
||||||
|
#define DEFAULT_URL "http://192.168.68.110:8098/463_Discovering_Discovery_Mountain_S01E01/discovery_mountain_s01e01_esp32_24k.mp3"
|
||||||
|
#define DEFAULT_PATH "/sdcard/download/test2.mp3"
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
lv_obj_t *bar;
|
||||||
|
lv_obj_t *lbl_url;
|
||||||
|
lv_obj_t *lbl_path;
|
||||||
|
lv_obj_t *lbl_pct;
|
||||||
|
lv_obj_t *lbl_detail;
|
||||||
|
lv_obj_t *lbl_status;
|
||||||
|
lv_obj_t *btn_cancel;
|
||||||
|
|
||||||
|
char url[512];
|
||||||
|
char path[512];
|
||||||
|
bool override_existing;
|
||||||
|
AppHandle app_handle;
|
||||||
|
|
||||||
|
TaskHandle_t dl_handle;
|
||||||
|
volatile bool downloading;
|
||||||
|
volatile bool cancel_req;
|
||||||
|
volatile int total;
|
||||||
|
volatile int expected;
|
||||||
|
int last_pct;
|
||||||
|
bool result_success;
|
||||||
|
} AppCtx;
|
||||||
|
|
||||||
|
static AppCtx G;
|
||||||
|
|
||||||
|
static void ensure_parent_dir(const char *filepath) {
|
||||||
|
if (!filepath) return;
|
||||||
|
char path_copy[512];
|
||||||
|
strncpy(path_copy, filepath, sizeof(path_copy)-1);
|
||||||
|
path_copy[sizeof(path_copy)-1]=0;
|
||||||
|
char *last_slash = strrchr(path_copy, '/');
|
||||||
|
if (!last_slash) return;
|
||||||
|
*last_slash = 0;
|
||||||
|
if (strlen(path_copy)==0) return;
|
||||||
|
|
||||||
|
char cur[512] = "";
|
||||||
|
if (path_copy[0]=='/') strcpy(cur, "/");
|
||||||
|
|
||||||
|
char *start = path_copy[0]=='/' ? path_copy+1 : path_copy;
|
||||||
|
int len = strlen(start);
|
||||||
|
int i = 0;
|
||||||
|
while (i <= len) {
|
||||||
|
int j = i;
|
||||||
|
while (j < len && start[j] != '/') j++;
|
||||||
|
if (j > i) {
|
||||||
|
size_t cur_len = strlen(cur);
|
||||||
|
if (cur_len > 0 && cur[cur_len-1] != '/') {
|
||||||
|
if (cur_len + 1 < sizeof(cur)) {
|
||||||
|
cur[cur_len] = '/';
|
||||||
|
cur[cur_len+1] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
char saved = start[j];
|
||||||
|
start[j] = 0;
|
||||||
|
strncat(cur, start+i, sizeof(cur)-strlen(cur)-1);
|
||||||
|
start[j] = saved;
|
||||||
|
mkdir(cur, 0777);
|
||||||
|
}
|
||||||
|
i = j+1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void update_ui_status(const char *txt) {
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
if (G.lbl_status) lv_label_set_text(G.lbl_status, txt);
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void set_result_and_close(bool success) {
|
||||||
|
G.result_success = success;
|
||||||
|
BundleHandle result_bundle = tt_bundle_alloc();
|
||||||
|
if (result_bundle) {
|
||||||
|
tt_bundle_put_bool(result_bundle, "success", success);
|
||||||
|
tt_bundle_put_string(result_bundle, "path", G.path);
|
||||||
|
tt_bundle_put_int32(result_bundle, "bytes", G.total);
|
||||||
|
if (!success) {
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
const char *status = G.lbl_status ? lv_label_get_text(G.lbl_status) : "failed";
|
||||||
|
char status_copy[128];
|
||||||
|
strncpy(status_copy, status, sizeof(status_copy)-1);
|
||||||
|
status_copy[sizeof(status_copy)-1]=0;
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
tt_bundle_put_string(result_bundle, "error", status_copy);
|
||||||
|
}
|
||||||
|
tt_app_set_result(G.app_handle, success ? APP_RESULT_OK : APP_RESULT_ERROR, result_bundle);
|
||||||
|
} else {
|
||||||
|
tt_app_set_result(G.app_handle, success ? APP_RESULT_OK : APP_RESULT_ERROR, NULL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool download_one_file(const char *url, const char *path) {
|
||||||
|
ESP_LOGI(TAG, "DL one file url=%s path=%s override=%d", url, path, G.override_existing);
|
||||||
|
|
||||||
|
struct stat st;
|
||||||
|
if (stat(path, &st)==0) {
|
||||||
|
if (!G.override_existing) {
|
||||||
|
ESP_LOGW(TAG, "File exists and override=false: %s", path);
|
||||||
|
update_ui_status("File exists");
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
ESP_LOGI(TAG, "File exists but override=true, overwriting %s", path);
|
||||||
|
unlink(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_parent_dir(path);
|
||||||
|
|
||||||
|
size_t free_internal = heap_caps_get_free_size(MALLOC_CAP_INTERNAL);
|
||||||
|
ESP_LOGI(TAG, "Heap before DL: internal=%d", free_internal);
|
||||||
|
if (free_internal < 10000) {
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen(url) < 8 || strncmp(url, "http://", 7)!=0) {
|
||||||
|
update_ui_status("Invalid URL");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
char tmp_path[520];
|
||||||
|
snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", path);
|
||||||
|
unlink(tmp_path);
|
||||||
|
FILE *f = fopen(tmp_path, "wb");
|
||||||
|
if (!f) {
|
||||||
|
ESP_LOGE(TAG, "fopen fail %s errno=%d", tmp_path, errno);
|
||||||
|
update_ui_status("Failed to open file");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_http_client_config_t cfg = {
|
||||||
|
.url = url,
|
||||||
|
.timeout_ms = 15000,
|
||||||
|
.buffer_size = 4096,
|
||||||
|
.buffer_size_tx = 1024,
|
||||||
|
.keep_alive_enable = false,
|
||||||
|
};
|
||||||
|
esp_http_client_handle_t client = esp_http_client_init(&cfg);
|
||||||
|
if (!client) {
|
||||||
|
fclose(f);
|
||||||
|
unlink(tmp_path);
|
||||||
|
update_ui_status("HTTP init failed");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t err = esp_http_client_open(client, 0);
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
esp_http_client_cleanup(client);
|
||||||
|
fclose(f);
|
||||||
|
unlink(tmp_path);
|
||||||
|
update_ui_status("HTTP open failed");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int content_len = esp_http_client_fetch_headers(client);
|
||||||
|
int status = esp_http_client_get_status_code(client);
|
||||||
|
ESP_LOGI(TAG, "HTTP status %d, content_len %d", status, content_len);
|
||||||
|
if (status != 200) {
|
||||||
|
char msg[64];
|
||||||
|
snprintf(msg, sizeof(msg), "HTTP %d", status);
|
||||||
|
update_ui_status(msg);
|
||||||
|
esp_http_client_close(client);
|
||||||
|
esp_http_client_cleanup(client);
|
||||||
|
fclose(f);
|
||||||
|
unlink(tmp_path);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
G.expected = content_len;
|
||||||
|
|
||||||
|
uint8_t *buf = heap_caps_malloc(4096, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||||
|
if (!buf) {
|
||||||
|
buf = malloc(4096);
|
||||||
|
if (!buf) {
|
||||||
|
esp_http_client_close(client);
|
||||||
|
esp_http_client_cleanup(client);
|
||||||
|
fclose(f);
|
||||||
|
unlink(tmp_path);
|
||||||
|
update_ui_status("No memory");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int total = 0;
|
||||||
|
int timeouts = 0;
|
||||||
|
bool ok = true;
|
||||||
|
|
||||||
|
while (1) {
|
||||||
|
if (G.cancel_req) { ok = false; break; }
|
||||||
|
if (content_len >=0 && total >= content_len) break;
|
||||||
|
|
||||||
|
int r = esp_http_client_read(client, (char*)buf, 4096);
|
||||||
|
if (r < 0) {
|
||||||
|
timeouts++;
|
||||||
|
if (timeouts >= 5) { ok = false; break; }
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(200));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (r == 0) break;
|
||||||
|
timeouts = 0;
|
||||||
|
if (fwrite(buf, 1, r, f) != (size_t)r) { ok = false; break; }
|
||||||
|
total += r;
|
||||||
|
G.total = total;
|
||||||
|
|
||||||
|
int pct = 0;
|
||||||
|
if (content_len > 0) {
|
||||||
|
pct = (int)((int64_t)total * 100 / content_len);
|
||||||
|
if (pct<0) pct=0;
|
||||||
|
if (pct>100) pct=100;
|
||||||
|
}
|
||||||
|
if (pct != G.last_pct) {
|
||||||
|
G.last_pct = pct;
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
if (G.bar) lv_bar_set_value(G.bar, pct, LV_ANIM_OFF);
|
||||||
|
if (G.lbl_detail) {
|
||||||
|
char d[64];
|
||||||
|
if (content_len>0) snprintf(d,sizeof(d),"%d / %d KB", total/1024, content_len/1024);
|
||||||
|
else snprintf(d,sizeof(d),"%d KB", total/1024);
|
||||||
|
lv_label_set_text(G.lbl_detail, d);
|
||||||
|
}
|
||||||
|
if (G.lbl_pct) {
|
||||||
|
char b[32];
|
||||||
|
if (content_len>0) snprintf(b,sizeof(b),"%d%%",pct);
|
||||||
|
else snprintf(b,sizeof(b),"%d KB", total/1024);
|
||||||
|
lv_label_set_text(G.lbl_pct, b);
|
||||||
|
}
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((total / 4096) % 8 == 0) {
|
||||||
|
fflush(f);
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
heap_caps_free(buf);
|
||||||
|
esp_http_client_close(client);
|
||||||
|
esp_http_client_cleanup(client);
|
||||||
|
fclose(f);
|
||||||
|
|
||||||
|
if (G.cancel_req) { unlink(tmp_path); update_ui_status("Canceled"); return false; }
|
||||||
|
if (!ok || total < 512) { unlink(tmp_path); update_ui_status("Failed – error"); return false; }
|
||||||
|
if (content_len >=0 && total < content_len * 95 / 100) { unlink(tmp_path); update_ui_status("Failed – incomplete"); return false; }
|
||||||
|
if (rename(tmp_path, path) != 0) { unlink(tmp_path); update_ui_status("Rename failed"); return false; }
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void download_task(void *arg) {
|
||||||
|
(void)arg;
|
||||||
|
G.downloading = true;
|
||||||
|
G.cancel_req = false;
|
||||||
|
G.last_pct = -1;
|
||||||
|
|
||||||
|
char url[512];
|
||||||
|
char path[512];
|
||||||
|
strncpy(url, G.url, sizeof(url)-1);
|
||||||
|
strncpy(path, G.path, sizeof(path)-1);
|
||||||
|
url[sizeof(url)-1]=0;
|
||||||
|
path[sizeof(path)-1]=0;
|
||||||
|
|
||||||
|
bool ok = download_one_file(url, path);
|
||||||
|
if (ok) {
|
||||||
|
char msg[64];
|
||||||
|
snprintf(msg, sizeof(msg), "Done! %d KB", G.total/1024);
|
||||||
|
update_ui_status(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
G.downloading = false;
|
||||||
|
G.dl_handle = NULL;
|
||||||
|
set_result_and_close(ok);
|
||||||
|
vTaskDelete(NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void btn_cancel_cb(lv_event_t *e) {
|
||||||
|
(void)e;
|
||||||
|
if (!G.downloading) { tt_app_stop(); return; }
|
||||||
|
G.cancel_req = true;
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
if (G.lbl_status) lv_label_set_text(G.lbl_status, "Canceling...");
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void build_ui(void) {
|
||||||
|
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_pad_all(root, 8, 0);
|
||||||
|
lv_obj_set_style_bg_color(root, lv_color_hex(0x111111), 0);
|
||||||
|
lv_obj_set_style_border_width(root, 0, 0);
|
||||||
|
lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN);
|
||||||
|
lv_obj_set_flex_align(root, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||||
|
lv_obj_set_style_pad_row(root, 6, 0);
|
||||||
|
|
||||||
|
lv_obj_t *title = lv_label_create(root);
|
||||||
|
lv_label_set_text(title, "Downloading...");
|
||||||
|
lv_obj_set_style_text_font(title, lvgl_get_text_font(FONT_SIZE_LARGE), 0);
|
||||||
|
lv_obj_set_style_text_color(title, lv_color_hex(0xFFFFFF), 0);
|
||||||
|
|
||||||
|
G.lbl_url = lv_label_create(root);
|
||||||
|
lv_label_set_text(G.lbl_url, G.url);
|
||||||
|
lv_obj_set_width(G.lbl_url, LV_PCT(100));
|
||||||
|
lv_label_set_long_mode(G.lbl_url, LV_LABEL_LONG_WRAP);
|
||||||
|
lv_obj_set_style_text_font(G.lbl_url, lvgl_get_text_font(FONT_SIZE_SMALL), 0);
|
||||||
|
lv_obj_set_style_text_color(G.lbl_url, lv_color_hex(0x888888), 0);
|
||||||
|
|
||||||
|
G.lbl_path = lv_label_create(root);
|
||||||
|
lv_label_set_text(G.lbl_path, G.path);
|
||||||
|
lv_obj_set_width(G.lbl_path, LV_PCT(100));
|
||||||
|
lv_label_set_long_mode(G.lbl_path, LV_LABEL_LONG_WRAP);
|
||||||
|
lv_obj_set_style_text_font(G.lbl_path, lvgl_get_text_font(FONT_SIZE_SMALL), 0);
|
||||||
|
lv_obj_set_style_text_color(G.lbl_path, lv_color_hex(0xAAAAAA), 0);
|
||||||
|
|
||||||
|
G.bar = lv_bar_create(root);
|
||||||
|
lv_obj_set_size(G.bar, LV_PCT(100), 14);
|
||||||
|
lv_bar_set_range(G.bar, 0, 100);
|
||||||
|
lv_bar_set_value(G.bar, 0, LV_ANIM_OFF);
|
||||||
|
lv_obj_set_style_bg_color(G.bar, lv_color_hex(0x333333), LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_bg_color(G.bar, lv_color_hex(0x2E7D32), LV_PART_INDICATOR);
|
||||||
|
|
||||||
|
lv_obj_t *row = lv_obj_create(root);
|
||||||
|
lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT);
|
||||||
|
lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0);
|
||||||
|
lv_obj_set_style_border_width(row, 0, 0);
|
||||||
|
lv_obj_set_style_pad_all(row, 0, 0);
|
||||||
|
lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW);
|
||||||
|
lv_obj_set_flex_align(row, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||||
|
|
||||||
|
G.lbl_pct = lv_label_create(row);
|
||||||
|
lv_label_set_text(G.lbl_pct, "0%");
|
||||||
|
lv_obj_set_style_text_font(G.lbl_pct, lvgl_get_text_font(FONT_SIZE_DEFAULT), 0);
|
||||||
|
|
||||||
|
G.lbl_detail = lv_label_create(row);
|
||||||
|
lv_label_set_text(G.lbl_detail, "-");
|
||||||
|
lv_obj_set_style_text_font(G.lbl_detail, lvgl_get_text_font(FONT_SIZE_SMALL), 0);
|
||||||
|
lv_obj_set_style_text_color(G.lbl_detail, lv_color_hex(0x888888), 0);
|
||||||
|
|
||||||
|
G.lbl_status = lv_label_create(root);
|
||||||
|
lv_label_set_text(G.lbl_status, "Starting...");
|
||||||
|
lv_obj_set_width(G.lbl_status, LV_PCT(100));
|
||||||
|
lv_label_set_long_mode(G.lbl_status, LV_LABEL_LONG_WRAP);
|
||||||
|
lv_obj_set_style_text_color(G.lbl_status, lv_color_hex(0xCCCCCC), 0);
|
||||||
|
|
||||||
|
G.btn_cancel = lv_button_create(root);
|
||||||
|
lv_obj_set_size(G.btn_cancel, 100, 40);
|
||||||
|
lv_obj_set_style_bg_color(G.btn_cancel, lv_color_hex(0x444444), 0);
|
||||||
|
lv_obj_t *lbl = lv_label_create(G.btn_cancel);
|
||||||
|
lv_label_set_text(lbl, "Cancel");
|
||||||
|
lv_obj_center(lbl);
|
||||||
|
lv_obj_add_event_cb(G.btn_cancel, btn_cancel_cb, LV_EVENT_CLICKED, NULL);
|
||||||
|
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void onShow(AppHandle app, void* data, lv_obj_t* parent) {
|
||||||
|
(void)data; (void)parent;
|
||||||
|
memset(&G, 0, sizeof(G));
|
||||||
|
G.app_handle = app;
|
||||||
|
G.last_pct = -1;
|
||||||
|
|
||||||
|
BundleHandle bundle = tt_app_get_parameters(app);
|
||||||
|
bool has_url = false;
|
||||||
|
bool has_path = false;
|
||||||
|
|
||||||
|
if (bundle) {
|
||||||
|
char url_buf[512] = {0};
|
||||||
|
char path_buf[512] = {0};
|
||||||
|
bool override_val = false;
|
||||||
|
|
||||||
|
if (tt_bundle_opt_string(bundle, "url", url_buf, sizeof(url_buf)) ||
|
||||||
|
tt_bundle_opt_string(bundle, "file", url_buf, sizeof(url_buf))) {
|
||||||
|
strncpy(G.url, url_buf, sizeof(G.url)-1);
|
||||||
|
has_url = true;
|
||||||
|
}
|
||||||
|
if (tt_bundle_opt_string(bundle, "path", path_buf, sizeof(path_buf)) ||
|
||||||
|
tt_bundle_opt_string(bundle, "dest", path_buf, sizeof(path_buf)) ||
|
||||||
|
tt_bundle_opt_string(bundle, "file_dest", path_buf, sizeof(path_buf))) {
|
||||||
|
strncpy(G.path, path_buf, sizeof(G.path)-1);
|
||||||
|
has_path = true;
|
||||||
|
}
|
||||||
|
if (tt_bundle_opt_bool(bundle, "override", &override_val)) {
|
||||||
|
G.override_existing = override_val;
|
||||||
|
} else {
|
||||||
|
bool ow2 = false;
|
||||||
|
if (tt_bundle_opt_bool(bundle, "overwrite", &ow2)) G.override_existing = ow2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!has_url) strncpy(G.url, DEFAULT_URL, sizeof(G.url)-1);
|
||||||
|
if (!has_path) strncpy(G.path, DEFAULT_PATH, sizeof(G.path)-1);
|
||||||
|
|
||||||
|
mkdir("/sdcard", 0777);
|
||||||
|
mkdir("/sdcard/download", 0777);
|
||||||
|
mkdir("/sdcard/dm", 0777);
|
||||||
|
|
||||||
|
build_ui();
|
||||||
|
|
||||||
|
bool should_autostart = has_url && has_path;
|
||||||
|
if (!has_url && !has_path) {
|
||||||
|
should_autostart = true;
|
||||||
|
tt_lvgl_lock(portMAX_DELAY);
|
||||||
|
if (G.lbl_status) lv_label_set_text(G.lbl_status, "Demo mode – no args, using defaults");
|
||||||
|
tt_lvgl_unlock();
|
||||||
|
}
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "Autostart=%d url=%s path=%s override=%d", should_autostart, G.url, G.path, G.override_existing);
|
||||||
|
|
||||||
|
if (should_autostart) {
|
||||||
|
BaseType_t res = xTaskCreate(download_task, "sd_dl", 8192, NULL, 5, &G.dl_handle);
|
||||||
|
ESP_LOGI(TAG, "xTaskCreate res=%d handle=%p", res, G.dl_handle);
|
||||||
|
if (res != pdPASS) update_ui_status("Failed to create DL task");
|
||||||
|
} else {
|
||||||
|
update_ui_status("Missing url/path args");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void onHide(AppHandle app, void* data) {
|
||||||
|
(void)app; (void)data;
|
||||||
|
if (G.dl_handle) {
|
||||||
|
G.cancel_req = true;
|
||||||
|
for (int i=0;i<20 && G.dl_handle!=NULL; i++) vTaskDelay(pdMS_TO_TICKS(100));
|
||||||
|
if (G.dl_handle) { vTaskDelete(G.dl_handle); G.dl_handle = NULL; G.downloading = false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char* argv[]) {
|
||||||
|
(void)argc; (void)argv;
|
||||||
|
tt_app_register((AppRegistration){ .onShow = onShow, .onHide = onHide });
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
manifest.version=0.2
|
||||||
|
target.sdk=0.8.0-dev
|
||||||
|
target.platforms=esp32s3
|
||||||
|
app.id=one.tactility.sddownloader
|
||||||
|
app.name=SD Downloader
|
||||||
|
app.version.name=1.0.0
|
||||||
|
app.version.code=1
|
||||||
Reference in New Issue
Block a user