17 Commits

Author SHA1 Message Date
Adolfo d4a257a01c feat(DM): instant cache load, manual refresh, full-screen season selector, season persistence fix
- Cache loads synchronously on onShow for instant startup, no auto fetch
- Added manual Refresh button, removed auto refresh that blocked UI
- Made UI non-blocking during fetch (episode list usable while fetching)
- Fixed season loss after player/downloader by preserving season in static s_preserved_season surviving G memset
- Season selector now full-screen overlay parented to lv_scr_act()
- Removed DL Missing batch button and bottom help/status label per UX request
- load_state no longer overwrites browsing season with last_slug season
2026-07-30 16:13:47 -04:00
Adolfo 7ef8e272fc feat(mp3,voice): fix scroll anim audio glitch, add seek/volume/history UI
- Mp3Player:
  * Disable LV_LABEL_LONG_SCROLL_CIRCULAR during playback (flag ENABLE_TITLE_SCROLL_ANIM_WHEN_IDLE) to avoid screen invalidation starving audio_stream
  * Single-line controls [-10s][Play/Pause][Stop][+10s] icon-only, volume slider at bottom, no card/toolbar, time label MM:SS / MM:SS updating every sec
  * Seek ±10s with proportional byte calc and correct sample count (was byte vs sample bug)
  * Safe history persistence: heap buffer (not stack), recursive mkdir, play_history.txt and volume.txt in user data
  * History only for >=10min audios, updated on pause/stop/hide, auto-resume latest
  * Full-screen history screen with Back button, open via top-right list icon, playing file highlighted CHECKED, tap to resume long or restart short

- VoiceRecorder:
  * Fix crash from lv_label_set_long_mode on icon child in list buttons (force DOT only on last child label)
  * Force DOT on status and memo list to avoid scroll anim during audio
  * Throttle UI updates (1Hz rec, %/sec change play)

Tested on 192.168.68.107, no crash, heap free ~15KB, screenshot verified
2026-07-30 15:00:34 -04:00
Adolfo eb364cb34f Scale Immich photos to cover the display 2026-07-29 22:00:18 -04:00
Adolfo eaccebc95d feat(DM+SdDDL): sequential full-season download queue, fix close crash, return true
- DM: add dl_missing_* fields to AppCtx for sequential queue
- DM player: count_missing_in_season, find_next_missing, download_episode, download_missing_season_start/next
- DM main onResult: auto-continue queue when dl_missing_active, updates status DL X/Y, finishes with done ok/fail
- SdDownloader: file exists returns true (Already on SD) with total=file size, not false
- SdDownloader: set_result_and_close minimal bundle (only success bool) to avoid internal heap OOM crash on close
- SdDownloader: Cancel button becomes Close after done, calls tt_app_stop() directly (manual close per request, auto-close unreliable)
- Fixes crash on close observed in demo mode (file exists) and after download done

Tested on 192.168.68.133: SdDownloader shows Already on SD + Close, DM shows S10 5/6 after sequential, no crash on open/close cycles
2026-07-25 22:49:28 -04:00
Adolfo 3778cb3399 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...'
2026-07-25 22:06:43 -04:00
Adolfo 6a1fe5dda6 feat(ImmichElias): add person selector like Bible books, 4 persons Elias/Grace/Alicia/Adolfo, picker UI with toolbar, player bottom-only, random pagination, cache, fullscreen 2026-07-25 16:29:24 -04:00
Adolfo d06f8ddbb5 fix(ImmichElias): black screen fix, 320x240 request for PSRAM stability (480x320 OOM), fullscreen widget 480x320 bg, bottom-only UI, tap toggle 2026-07-25 16:14:34 -04:00
Adolfo a2bb8e5132 feat(ImmichElias): 2x render 480x320 fullscreen bg, display detection, bottom-only UI, PSRAM 2026-07-25 16:11:29 -04:00
Adolfo 9ee0599bec fix(ImmichElias): black screen fix, 240x180 request for PSRAM memory safety, fullscreen bg widget 480x320, bottom-only UI, tap toggle, cache random pagination 2026-07-25 16:09:45 -04:00
Adolfo c3689e82cc fix(ImmichElias): black screen fix, clear hidden flag, fullscreen 480x320 bg, bottom-only UI tap toggle, filter VIDEO, random pagination 100, cache 2026-07-25 16:05:59 -04:00
Adolfo 4c71c77bf6 fix(ImmichElias): show >50 photos via random pagination, filter IMAGE only, increase to 100, JSON 128KB, slideshow + cache 2026-07-25 16:00:18 -04:00
Adolfo 0595e149d4 fix(ImmichElias): bottom-only UI like Bible, fullscreen 480x320 bg, tap toggles chrome, PSRAM, random, cache with offline fallback, filter IMAGE only, proxy fallback 320x240, slideshow 60s, crash fixes for touch 2026-07-25 15:53:37 -04:00
Adolfo 995297314d fix(ImmichElias): bottom-only UI like Bible, fullscreen bg, tap to toggle chrome, dynamic 480x320, cache, random, slideshow 60s, crash fixes 2026-07-25 15:47:08 -04:00
Adolfo 349034b8ba fix(ImmichElias): fullscreen background 480x320, BookPlayer UI, no crop when hidden, PSRAM for images, random, cache offline, slideshow 60s, crash fixes 2026-07-25 15:39:08 -04:00
Adolfo 3085bac5f1 fix(ImmichElias): merge dynamic sizing 480x320 + crash fix, BookPlayer UI, random, cache offline, slideshow 60s 2026-07-25 15:24:29 -04:00
Hermes Reyna Bot b03cb2b244 fix(ImmichElias): PNG via resize proxy 8106 for real photos, fixes WebP crash, device 133 yellow case
- Proxy already fixed on FamReynaServer 192.168.68.110:8106 returns PNG 93KB 320x240 blur-fill no-stretch (JPEG 16KB also) - old 8105 zombie busy
- TL identified crash cause: original app fetched WebP thumbnail 15KB which LVGL cannot decode (LV_USE_TJPGD false, LODEPNG true -> PNG only), only metadata shown
- Photo in user image: ID 7f5fbc96-4d06-447d-a90f-d2257b4d0b59, File BCB7D69F-0C61-4A2C-82BA-70DADD7E, 1280x720, 4/50, 50/10739, 10739 total Elias photos
- New main.c 219+: RESIZE_PROXY_URL 8106, /data/immich_cur.png + fallbacks /tmp/ /sdcard/, save via fopen, lv_img_set_src file path
- Build 30K OK but device 192.168.68.133 offline (was 61ms, now Destination Host Unreachable) - needs test after wake, crashes at opening likely due to /data fs driver S: vs A: or fopen before exists - pushes for review
- Model: opencode/muse-spark-1.1 via api.meta.ai same key, tmux visible engineer window 2:engineer on iMac .124, persistent session opencode handles cache better per user
- Branch feature/immich-elias-client
2026-07-25 13:40:50 -04:00
Hermes Reyna Bot 3a2e6f3f11 feat(ImmichElias): implement Elias photo browser client
- Define IMMICH_BASE_URL as http://192.168.68.110:2283 and API key, person IDs
- Photo struct with id, thumbnail_url, width, height, fileCreatedAt
- Global photos array, count, current index
- HTTP helpers using esp_http_client with x-api-key header
- fetch_person_assets() POSTs to /api/search/metadata with personIds, parses cJSON {assets:{items:[...]}}
- download_thumbnail() GETs /api/assets/{id}/thumbnail?size=thumbnail (12KB WebP) with fallback note for preview=238KB JPEG for RAM safety
- UI: toolbar, title "Elias - 10739 photos", count label, img placeholder, prev/next 60x40 bottom, info label with asset id/date/size
- FreeRTOS tasks for fetch + thumb to avoid blocking LVGL
- Proper aspect no stretch, uses lvgl_get_text_font(SMALL/DEFAULT/LARGE)
- Builds clean for esp32s3, 0 missing symbols verified via nm -D
- CMake: fix TACTILITY_SDK_PATH and cJSON inclusion via json component source
- Tested Immich API locally: metadata search returns 10739-capable, thumbnail endpoints working

Co-Authored-By: internal-model
2026-07-25 12:35:54 -04:00
23 changed files with 4248 additions and 962 deletions
+259
View File
@@ -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`
@@ -17,17 +17,18 @@ extern "C" {
#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 400
#define MP3_BUF 16384
#define MAX_EPS 250
#define MP3_BUF 4096
typedef struct { int num; } SeasonInfo;
typedef struct {
char title[128];
char title[64];
char slug[96];
int season;
int ep_num;
char audio_url[256];
char audio_url[128];
bool seen;
} EpInfo;
@@ -52,8 +53,15 @@ typedef struct {
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;
@@ -65,10 +73,11 @@ typedef struct {
char last_slug[96];
bool fetching;
bool downloading;
int dl_total;
volatile int dl_total;
int dl_expected;
int dl_last_ui_update;
bool dl_cancel_req;
volatile bool dl_cancel_req;
volatile bool fetch_cancel_req;
char dl_ep_title[128];
char dl_slug[96];
int dl_season;
@@ -78,8 +87,25 @@ typedef struct {
SavedPos saved_pos[MAX_EPS];
int saved_pos_cnt;
int last_save_tick;
// External downloader integration (SdDownloader)
int pending_dl_idx;
// Full season download queue (sequential single-file to avoid batch crash)
bool dl_missing_active;
int dl_missing_season;
int dl_missing_total;
int dl_missing_done;
int dl_missing_failed;
// Refresh button reference
lv_obj_t *btn_refresh;
} AppCtx;
void trigger_manual_refresh(void);
void dm_preserve_season(int season);
int dm_consume_preserved_season(void);
extern AppCtx G;
#ifdef __cplusplus
+11 -355
View File
@@ -1,26 +1,11 @@
#include "download.h"
#include "storage.h"
#include "network.h"
#include "player.h"
#include <tt_lvgl.h>
#include <tactility/lvgl_fonts.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <lwip/sockets.h>
#include <lwip/inet.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include <unistd.h>
#include <errno.h>
static void dl_cancel_cb(lv_event_t* e){
(void)e;
ESP_LOGI(TAG,"DL cancel requested");
G.dl_cancel_req=true;
if(G.dl_lbl_detail) lv_label_set_text(G.dl_lbl_detail,"Canceling...");
}
// 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){
@@ -36,358 +21,29 @@ void hide_dl_overlay(){
}
void show_dl_overlay_locked(const char* ep_title, const char* slug, int season, int ep_num){
if(G.dl_overlay) hide_dl_overlay();
strncpy(G.dl_ep_title, ep_title ? ep_title : "Episode", sizeof(G.dl_ep_title)-1);
strncpy(G.dl_slug, slug ? slug : "", sizeof(G.dl_slug)-1);
G.dl_season=season;
G.dl_ep_num=ep_num;
G.dl_total=0;
G.dl_expected=-1;
G.dl_last_pct=-1;
G.dl_cancel_req=false;
// 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;
G.dl_overlay = lv_obj_create(lv_scr_act());
lv_obj_set_size(G.dl_overlay, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_bg_color(G.dl_overlay, lv_color_hex(0x000000), 0);
lv_obj_set_style_bg_opa(G.dl_overlay, LV_OPA_70, 0);
lv_obj_set_style_border_width(G.dl_overlay, 0, 0);
lv_obj_set_style_pad_all(G.dl_overlay, 0, 0);
lv_obj_set_style_radius(G.dl_overlay, 0, 0);
lv_obj_remove_flag(G.dl_overlay, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* card = lv_obj_create(G.dl_overlay);
lv_obj_set_size(card, 220, 210);
lv_obj_center(card);
lv_obj_set_style_bg_color(card, lv_color_hex(0x1E1E2E), 0);
lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0);
lv_obj_set_style_border_color(card, lv_color_hex(0x313244), 0);
lv_obj_set_style_border_width(card, 1, 0);
lv_obj_set_style_radius(card, 12, 0);
lv_obj_set_style_pad_all(card, 12, 0);
lv_obj_set_style_pad_row(card, 6, 0);
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(card, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* lbl_head = lv_label_create(card);
lv_label_set_text(lbl_head, "Downloading");
lv_obj_set_style_text_color(lbl_head, lv_color_hex(0xCDD6F4), 0);
lv_obj_set_style_text_font(lbl_head, lvgl_get_text_font(FONT_SIZE_DEFAULT), 0);
G.dl_lbl_title = lv_label_create(card);
lv_label_set_text(G.dl_lbl_title, G.dl_ep_title);
lv_obj_set_width(G.dl_lbl_title, 190);
lv_obj_set_style_text_color(G.dl_lbl_title, lv_color_hex(0xFFFFFF), 0);
lv_obj_set_style_text_align(G.dl_lbl_title, LV_TEXT_ALIGN_CENTER, 0);
lv_label_set_long_mode(G.dl_lbl_title, LV_LABEL_LONG_WRAP);
G.dl_lbl_sub = lv_label_create(card);
char sub[64];
snprintf(sub, sizeof(sub), "S%02d E%02d • %s", season, ep_num, slug);
lv_label_set_text(G.dl_lbl_sub, sub);
lv_obj_set_style_text_color(G.dl_lbl_sub, lv_color_hex(0xA6ADC8), 0);
lv_obj_set_style_text_font(G.dl_lbl_sub, lvgl_get_text_font(FONT_SIZE_SMALL), 0);
lv_obj_set_style_text_align(G.dl_lbl_sub, LV_TEXT_ALIGN_CENTER, 0);
G.dl_bar = lv_bar_create(card);
lv_obj_set_size(G.dl_bar, 190, 14);
lv_bar_set_range(G.dl_bar, 0, 100);
lv_bar_set_value(G.dl_bar, 0, LV_ANIM_OFF);
lv_obj_set_style_bg_color(G.dl_bar, lv_color_hex(0x45475A), LV_PART_MAIN);
lv_obj_set_style_bg_color(G.dl_bar, lv_color_hex(0x89B4FA), LV_PART_INDICATOR);
lv_obj_set_style_radius(G.dl_bar, 7, 0);
G.dl_lbl_pct = lv_label_create(card);
lv_label_set_text(G.dl_lbl_pct, "0%");
lv_obj_set_style_text_color(G.dl_lbl_pct, lv_color_hex(0xCDD6F4), 0);
lv_obj_set_style_text_font(G.dl_lbl_pct, lvgl_get_text_font(FONT_SIZE_DEFAULT), 0);
G.dl_lbl_detail = lv_label_create(card);
lv_label_set_text(G.dl_lbl_detail, "Starting…");
lv_obj_set_style_text_color(G.dl_lbl_detail, lv_color_hex(0xA6ADC8), 0);
lv_obj_set_style_text_font(G.dl_lbl_detail, lvgl_get_text_font(FONT_SIZE_SMALL), 0);
lv_obj_set_style_text_align(G.dl_lbl_detail, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_width(G.dl_lbl_detail, 190);
lv_label_set_long_mode(G.dl_lbl_detail, LV_LABEL_LONG_WRAP);
lv_obj_t* btn_cancel = lv_button_create(card);
lv_obj_set_size(btn_cancel, 90, 32);
lv_obj_set_style_radius(btn_cancel, 8, 0);
lv_obj_set_style_bg_color(btn_cancel, lv_color_hex(0x313244), 0);
lv_obj_t* lbl_c = lv_label_create(btn_cancel);
lv_label_set_text(lbl_c, "Cancel");
lv_obj_center(lbl_c);
lv_obj_add_event_cb(btn_cancel, dl_cancel_cb, LV_EVENT_CLICKED, NULL);
}
void update_dl_overlay_ui(){
if(!G.downloading || !G.dl_overlay) return;
if(G.dl_bar){
int pct=0;
if(G.dl_expected>0){
pct = (int)((int64_t)G.dl_total * 100 / G.dl_expected);
if(pct<0) pct=0;
if(pct>100) pct=100;
} else {
pct = G.dl_last_pct;
if(pct<0) pct=0;
}
if(pct!=G.dl_last_pct){
G.dl_last_pct=pct;
lv_bar_set_value(G.dl_bar, pct, LV_ANIM_OFF);
}
}
if(G.dl_lbl_pct){
char b[16];
if(G.dl_expected>0){
int pct = (int)((int64_t)G.dl_total * 100 / G.dl_expected);
if(pct<0) pct=0;
if(pct>100) pct=100;
snprintf(b,sizeof(b),"%d%%",pct);
} else {
snprintf(b,sizeof(b),"%d KB",G.dl_total/1024);
}
lv_label_set_text(G.dl_lbl_pct,b);
}
if(G.dl_lbl_detail){
char d[64];
if(G.dl_expected>0){
int total_kb = G.dl_total/1024;
int exp_kb = G.dl_expected/1024;
if(exp_kb>1024){
snprintf(d,sizeof(d),"%d KB / %d KB (%d.%01d MB)", total_kb, exp_kb, exp_kb/1024, (exp_kb%1024)*10/1024);
} else {
snprintf(d,sizeof(d),"%d / %d KB", total_kb, exp_kb);
}
} else {
snprintf(d,sizeof(d),"%d KB downloaded", G.dl_total/1024);
}
lv_label_set_text(G.dl_lbl_detail,d);
}
// No internal download progress anymore
}
bool dl_ep_raw(EpInfo* ep){
if(!ep) return false;
if(strlen(ep->audio_url)==0){ ESP_LOGE(TAG,"dl no url for %s",ep->slug); return false; }
char url_norm[512];
normalize_audio_url(ep->audio_url, url_norm, sizeof(url_norm));
ESP_LOGI(TAG,"dl url normalized: %s -> %s", ep->audio_url, url_norm);
const char* url=url_norm;
if(strncmp(url,"http://",7)!=0){
ESP_LOGE(TAG,"dl url not http %s",url);
return false;
}
const char* p=url+7;
const char* slash=strchr(p,'/');
if(!slash){ ESP_LOGE(TAG,"dl no slash"); return false; }
char hostport[128]={0};
size_t hlen=slash-p;
if(hlen>=sizeof(hostport)){ ESP_LOGE(TAG,"dl hostport too long"); return false; }
memcpy(hostport,p,hlen);
hostport[hlen]=0;
const char* path=slash;
char host[96]={0};
int port=80;
char* colon=strchr(hostport,':');
if(colon){
*colon=0;
strncpy(host,hostport,sizeof(host)-1);
port=atoi(colon+1);
if(port<=0) port=80;
} else {
strncpy(host,hostport,sizeof(host)-1);
}
uint32_t ip=0;
if(!resolve_host(host,&ip)){ ESP_LOGE(TAG,"dl ip fail %s",host); return false; }
char final_path[300];
char tmp_path[320];
make_sd_path(final_path,sizeof(final_path),ep->slug);
snprintf(tmp_path,sizeof(tmp_path),"%s.tmp",final_path);
unlink(tmp_path);
FILE* f=fopen(tmp_path,"wb");
if(!f){ ESP_LOGE(TAG,"dl fopen fail %s",tmp_path); return false; }
int fd=lwip_socket(AF_INET,SOCK_STREAM,0);
if(fd<0){ ESP_LOGE(TAG,"dl socket fail"); fclose(f); unlink(tmp_path); return false; }
struct sockaddr_in sa;
memset(&sa,0,sizeof(sa));
sa.sin_family=AF_INET;
sa.sin_port=my_htons(port);
sa.sin_addr.s_addr=ip;
struct timeval tv={15,0};
lwip_setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&tv,sizeof(tv));
lwip_setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&tv,sizeof(tv));
ESP_LOGI(TAG,"dl connecting %s:%d",host,port);
if(lwip_connect(fd,(struct sockaddr*)&sa,sizeof(sa))<0){
ESP_LOGE(TAG,"dl connect fail %s:%d errno=%d",host,port,errno);
fclose(f); close(fd); unlink(tmp_path); return false;
}
ESP_LOGI(TAG,"dl connected");
char req[512];
int reqlen=snprintf(req,sizeof(req),"GET %s HTTP/1.0\r\nHost: %s:%d\r\nConnection: close\r\nUser-Agent: TactilityDM/0.1\r\nAccept: */*\r\n\r\n",path,host,port);
if(lwip_send(fd,req,reqlen,0)<0){
ESP_LOGE(TAG,"dl send fail errno=%d",errno);
fclose(f); close(fd); unlink(tmp_path); return false;
}
ESP_LOGI(TAG,"dl request sent");
char* hdr=heap_caps_malloc(8192, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if(!hdr){ fclose(f); close(fd); unlink(tmp_path); return false; }
int hdr_len=0;
bool header_done=false;
int content_len=-1;
int total=0;
uint8_t* recv_buf=heap_caps_malloc(8192, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if(!recv_buf){ heap_caps_free(hdr); fclose(f); close(fd); unlink(tmp_path); return false; }
G.dl_total=0;
G.dl_expected=-1;
G.dl_last_pct=-1;
while(!header_done){
if(G.dl_cancel_req){ ESP_LOGI(TAG,"dl canceled during header"); heap_caps_free(hdr); heap_caps_free(recv_buf); fclose(f); close(fd); unlink(tmp_path); return false; }
int r=lwip_recv(fd,(char*)recv_buf,4096,0);
if(r<=0){
ESP_LOGE(TAG,"dl recv header fail r=%d errno=%d",r,errno);
heap_caps_free(hdr); heap_caps_free(recv_buf); fclose(f); close(fd); unlink(tmp_path); return false;
}
if(hdr_len+r >= 8191){ ESP_LOGE(TAG,"dl hdr overflow"); heap_caps_free(hdr); heap_caps_free(recv_buf); fclose(f); close(fd); unlink(tmp_path); return false; }
memcpy(hdr+hdr_len, recv_buf, r);
hdr_len+=r;
hdr[hdr_len]=0;
char* he=strstr(hdr,"\r\n\r\n");
if(he){
header_done=true;
if(strncmp(hdr,"HTTP/1.1 200",12)!=0 && strncmp(hdr,"HTTP/1.0 200",12)!=0){
ESP_LOGE(TAG,"dl not 200: %.120s",hdr);
heap_caps_free(hdr); heap_caps_free(recv_buf); fclose(f); close(fd); unlink(tmp_path); return false;
}
char* cl=strstr(hdr,"Content-Length:");
if(!cl) cl=strstr(hdr,"content-length:");
if(cl){
cl=strchr(cl,':');
if(cl){ cl++; while(*cl==' ') cl++; content_len=atoi(cl); }
}
ESP_LOGI(TAG,"dl header done cl=%d",content_len);
G.dl_expected=content_len;
int body_start = (he - hdr) + 4;
int body_in_buf = hdr_len - body_start;
if(body_in_buf>0){
fwrite(hdr+body_start,1,body_in_buf,f);
total+=body_in_buf;
G.dl_total=total;
ESP_LOGI(TAG,"dl wrote initial %d",body_in_buf);
}
break;
}
}
ESP_LOGI(TAG,"dl streaming body cl=%d",content_len);
int consecutive_timeouts=0;
const int max_timeouts=12;
while(1){
if(G.dl_cancel_req){
ESP_LOGI(TAG,"dl canceled during body total=%d",total);
heap_caps_free(hdr);
heap_caps_free(recv_buf);
close(fd);
fclose(f);
unlink(tmp_path);
return false;
}
if(content_len>=0 && total>=content_len) break;
int r=lwip_recv(fd,(char*)recv_buf,8192,0);
if(r<0){
if(errno==EAGAIN || errno==EWOULDBLOCK || errno==ETIMEDOUT || errno==0){
consecutive_timeouts++;
ESP_LOGW(TAG,"dl timeout %d/%d total=%d/%d",consecutive_timeouts,max_timeouts,total,content_len);
if(consecutive_timeouts>=max_timeouts){
ESP_LOGE(TAG,"dl too many timeouts, aborting");
break;
}
vTaskDelay(pdMS_TO_TICKS(200));
continue;
} else {
ESP_LOGE(TAG,"dl recv error r=%d errno=%d",r,errno);
break;
}
}
if(r==0){
ESP_LOGI(TAG,"dl EOF total=%d expected=%d",total,content_len);
break;
}
consecutive_timeouts=0;
fwrite(recv_buf,1,r,f);
total+=r;
G.dl_total=total;
}
heap_caps_free(hdr);
heap_caps_free(recv_buf);
close(fd);
fclose(f);
ESP_LOGI(TAG,"DL done total %d expected %d",total,content_len);
if(G.dl_cancel_req){
ESP_LOGI(TAG,"DL canceled, removing tmp");
unlink(tmp_path);
return false;
}
if(total<1024){ ESP_LOGE(TAG,"dl too small %d",total); unlink(tmp_path); return false; }
if(content_len>=0 && total < content_len){
if(total < content_len * 95 / 100){
ESP_LOGE(TAG,"dl incomplete %d < %d (95%% threshold)",total,content_len);
unlink(tmp_path);
return false;
} else {
ESP_LOGW(TAG,"dl size slightly short %d vs %d, accepting",total,content_len);
}
}
if(rename(tmp_path,final_path)!=0){
ESP_LOGE(TAG,"rename failed %s -> %s errno=%d",tmp_path,final_path,errno);
unlink(tmp_path);
return false;
}
ep->seen=true;
return true;
(void)ep;
return false;
}
bool dl_ep(EpInfo* ep){
if(!ep) return false;
if(has_slug(ep->slug)) return true;
ensure_dir();
lru_check();
return dl_ep_raw(ep);
(void)ep;
return false;
}
void dl_task(void* arg){
int idx = (int)(intptr_t)arg;
G.downloading=true;
G.dl_cancel_req=false;
if(idx<0||idx>=G.ep_cnt){ G.downloading=false; G.dl_handle=NULL; vTaskDelete(NULL); return; }
EpInfo* ep=&G.eps[idx];
ESP_LOGI(TAG,"DL start %s url %s",ep->slug,ep->audio_url);
bool ok=dl_ep(ep);
ESP_LOGI(TAG,"DL end %s ok %d",ep->slug,ok);
tt_lvgl_lock(portMAX_DELAY);
hide_dl_overlay();
if(G.lbl_status){
if(ok) lv_label_set_text(G.lbl_status,"DL done, playing...");
else {
if(G.dl_cancel_req) lv_label_set_text(G.lbl_status,"DL canceled");
else lv_label_set_text(G.lbl_status,"DL failed – tap Play to retry");
}
}
tt_lvgl_unlock();
(void)arg;
G.downloading=false;
G.dl_handle=NULL;
if(ok){
start_idx_internal(idx);
}
vTaskDelete(NULL);
}
+421 -51
View File
@@ -1,5 +1,6 @@
#include <tt_app.h>
#include <tt_lvgl.h>
#include <tt_bundle.h>
#include "app_context.h"
#include "storage.h"
#include "network.h"
@@ -11,111 +12,480 @@
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_heap_caps.h"
// Preserved browsing season that survives G memset across external app launch/return
// This fixes season reset to 1 when returning from player/downloader where file read may race
static int s_preserved_season = -1;
void dm_preserve_season(int season){
if(season>0) s_preserved_season = season;
}
int dm_consume_preserved_season(void){
int s = s_preserved_season;
s_preserved_season = -1;
return s;
}
static void fetch_task_fn(void* arg){
(void)arg;
G.fetching=true;
ESP_LOGI(TAG,"fetch task start");
fetch_data();
ESP_LOGI(TAG,"fetch done seasons=%d eps=%d",G.season_cnt,G.ep_cnt);
if(strlen(G.last_slug)>0){
for(int i=0;i<G.ep_cnt;i++) if(strcmp(G.eps[i].slug,G.last_slug)==0){ G.cur_ep_idx=i; G.cur_season=G.eps[i].season; break; }
}
if(G.cur_ep_idx<0){
int s1=-1;
for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==G.cur_season){ s1=i; break; }
if(s1==-1) s1=0;
G.cur_ep_idx=s1;
}
G.fetch_cancel_req=false;
ESP_LOGI(TAG,"fetch task start (manual refresh)");
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status) lv_label_set_text(G.lbl_status,"Fetching from network...");
tt_lvgl_unlock();
// Preserve user's current season selection across fetch
int preserved_season = G.cur_season;
char preserved_slug[96]={0};
if(G.cur_ep_idx>=0 && G.cur_ep_idx < G.ep_cnt){
strncpy(preserved_slug, G.eps[G.cur_ep_idx].slug, sizeof(preserved_slug)-1);
} else if(strlen(G.last_slug)>0){
strncpy(preserved_slug, G.last_slug, sizeof(preserved_slug)-1);
}
bool fetch_ok = fetch_data();
if(G.fetch_cancel_req){
ESP_LOGI(TAG,"fetch task canceled");
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 {
ESP_LOGW(TAG,"Network fetch failed");
// Do not overwrite current data if fetch fails – keep existing cache in memory
// If we have no data at all, fetch_data already created fallback
}
ESP_LOGI(TAG,"fetch done seasons=%d eps=%d fetch_ok=%d", G.season_cnt, G.ep_cnt, fetch_ok);
// Restore preserved season if it still exists
if(preserved_season>0){
bool season_exists=false;
for(int i=0;i<G.season_cnt;i++) if(G.seasons[i].num==preserved_season){ season_exists=true; break; }
if(season_exists){
G.cur_season = preserved_season;
// try to restore slug within preserved season
int best=-1;
if(strlen(preserved_slug)>0){
for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==preserved_season && strcmp(G.eps[i].slug,preserved_slug)==0){ best=i; break; }
}
if(best==-1){
for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==preserved_season){ best=i; break; }
}
if(best!=-1) G.cur_ep_idx=best;
strncpy(G.last_slug, preserved_slug, sizeof(G.last_slug)-1);
} else {
// Fallback to preserved slug or first
if(strlen(preserved_slug)>0){
for(int i=0;i<G.ep_cnt;i++) if(strcmp(G.eps[i].slug,preserved_slug)==0){ G.cur_ep_idx=i; G.cur_season=G.eps[i].season; break; }
}
if(G.cur_ep_idx<0 && G.ep_cnt>0){
G.cur_ep_idx=0;
G.cur_season=G.eps[0].season;
}
}
} else {
if(G.cur_ep_idx<0 && G.ep_cnt>0){
G.cur_ep_idx=0;
G.cur_season=G.eps[0].season;
}
}
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(G.lbl_title) lv_label_set_text(G.lbl_title,ep->title);
if(G.lbl_meta){
char b[64];
if(saved>5){
char tbuf[16];
fmt_time(tbuf,saved);
snprintf(b,sizeof(b),"S%02d E%02d %s • %s",ep->season,ep->ep_num,ep->seen?"*":"",tbuf);
} else {
snprintf(b,sizeof(b),"S%02d E%02d %s",ep->season,ep->ep_num,ep->seen?"*":"");
}
lv_label_set_text(G.lbl_meta,b);
}
if(G.lbl_status){
if(saved>5) lv_label_set_text(G.lbl_status,"Resume – tap Play");
else lv_label_set_text(G.lbl_status,ep->seen?"On SD – tap Play":"Tap Play to DL");
}
if(G.bar) lv_bar_set_value(G.bar,0,LV_ANIM_OFF);
if(G.lbl_time){
if(saved>5){
char a[16];
fmt_time(a,saved);
char buf[40];
snprintf(buf,sizeof(buf),"%s / --:--",a);
lv_label_set_text(G.lbl_time,buf);
} else {
lv_label_set_text(G.lbl_time,"0:00 / --:--");
}
}
if(saved>0) G.pos_sec=saved;
} else {
if(G.lbl_status) lv_label_set_text(G.lbl_status,"No data");
}
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status){
if(G.ep_cnt>0){
if(fetch_ok) lv_label_set_text(G.lbl_status, "Refreshed – select episode");
else lv_label_set_text(G.lbl_status, "Refresh failed – using cache");
} else {
lv_label_set_text(G.lbl_status,"No data – check network");
}
}
tt_lvgl_unlock();
G.fetching=false;
G.fetch_handle=NULL;
save_state();
vTaskDelete(NULL);
}
void trigger_manual_refresh(void){
if(G.fetching){
ESP_LOGI(TAG,"refresh already in progress");
return;
}
if(G.fetch_handle){
ESP_LOGW(TAG,"fetch handle still present, not starting new");
return;
}
G.fetch_cancel_req=false;
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,"trigger_manual_refresh heap: 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 manual refresh 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 start refresh");
tt_lvgl_unlock();
G.fetch_handle=NULL;
}
}
static void onShow(AppHandle app, void* data, lv_obj_t* parent){
(void)app; (void)data; (void)parent;
// Check for preserved season from external app launch (survives G memset)
int preserved = dm_consume_preserved_season();
if(preserved>0){
ESP_LOGI(TAG,"onShow preserved season S%02d from previous launch", preserved);
}
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();
xTaskCreate(fetch_task_fn,"dm_fetch",16384,NULL,5,&G.fetch_handle);
// If we have a preserved season from launching player/downloader, it overrides file
if(preserved>0){
G.cur_season = preserved;
ESP_LOGI(TAG,"onShow overriding cur_season with preserved S%02d", preserved);
}
build_ui(parent);
// Instant cache load – no network, fully synchronous for fast startup
// NOTE: load_state() already loaded last_season (browsing season) and last_slug (last played)
// We want to preserve browsing season, NOT overwrite it with last_slug's season.
bool cache_loaded = load_cache();
if(cache_loaded){
ESP_LOGI(TAG,"Cache loaded instantly: %d seasons, %d eps browsing S%02d last_slug=%s preserved=%d", G.season_cnt, G.ep_cnt, G.cur_season, G.last_slug, preserved);
// Validate browsing season still exists, otherwise fallback to first season
bool season_exists=false;
for(int i=0;i<G.season_cnt;i++) if(G.seasons[i].num==G.cur_season){ season_exists=true; break; }
if(!season_exists){
// If we had a preserved season but it's not in seasons list, maybe list incomplete – try preserved again
if(preserved>0){
for(int i=0;i<G.season_cnt;i++) if(G.seasons[i].num==preserved){ season_exists=true; G.cur_season=preserved; break; }
}
if(!season_exists && G.season_cnt>0){
ESP_LOGW(TAG,"Browsing season S%02d not found in cache, fallback to S%02d", G.cur_season, G.seasons[0].num);
G.cur_season = G.seasons[0].num;
season_exists=true;
}
}
// Resolve cur_ep_idx: prefer last_slug if it is within browsing season, otherwise first ep in browsing season
G.cur_ep_idx=-1;
if(strlen(G.last_slug)>0 && season_exists){
for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==G.cur_season && strcmp(G.eps[i].slug,G.last_slug)==0){ G.cur_ep_idx=i; break; }
}
if(G.cur_ep_idx<0){
for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==G.cur_season){ G.cur_ep_idx=i; break; }
}
if(G.cur_ep_idx<0 && G.ep_cnt>0){
G.cur_ep_idx=0;
G.cur_season=G.eps[0].season;
}
ui_rebuild_episode_list();
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status) lv_label_set_text(G.lbl_status,"Loaded from cache – tap Refresh to update");
tt_lvgl_unlock();
} else {
ESP_LOGI(TAG,"No cache at startup, cur_season S%02d preserved %d", G.cur_season, preserved);
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status) lv_label_set_text(G.lbl_status,"No cache – tap Refresh");
tt_lvgl_unlock();
ui_rebuild_episode_list();
}
// Final safeguard: if preserved was set, ensure it is saved back to state file so next open keeps it
if(preserved>0){
save_state();
}
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 after cache load: internal=%d psram=%d cache=%d cur_season=%d", free_internal, free_psram, cache_loaded, G.cur_season);
}
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();
// Handle sequential full-season download queue (safe single-file mode)
if(G.dl_missing_active){
if(success) G.dl_missing_done++;
else G.dl_missing_failed++;
int next = find_next_missing_in_season(G.dl_missing_season);
if(next!=-1){
ESP_LOGI(TAG, "Batch queue continuing: %d/%d done, next idx %d", G.dl_missing_done, G.dl_missing_total, next);
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status){
char msg[64];
snprintf(msg,sizeof(msg),"DL %d/%d S%02d...", G.dl_missing_done+1, G.dl_missing_total, G.dl_missing_season);
lv_label_set_text(G.lbl_status, msg);
}
tt_lvgl_unlock();
// Small delay to let UI update before launching next downloader
vTaskDelay(pdMS_TO_TICKS(300));
if(download_episode(next)){
// started next
return;
} else {
// failed to start next, abort queue
G.dl_missing_active=false;
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status){
char msg[64];
snprintf(msg,sizeof(msg),"DL stopped %d ok %d fail", G.dl_missing_done, G.dl_missing_failed);
lv_label_set_text(G.lbl_status, msg);
}
tt_lvgl_unlock();
return;
}
} else {
// No more missing – queue finished
ESP_LOGI(TAG, "Batch queue finished: %d ok %d fail", G.dl_missing_done, G.dl_missing_failed);
G.dl_missing_active=false;
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status){
char msg[64];
snprintf(msg,sizeof(msg),"DL S%02d done %d ok %d fail", G.dl_missing_season, G.dl_missing_done, G.dl_missing_failed);
lv_label_set_text(G.lbl_status, msg);
}
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);
return;
}
}
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();
tt_lvgl_lock(portMAX_DELAY);
G.fetch_cancel_req=true;
G.dl_cancel_req=true;
G.dl_missing_active=false;
if(G.fetch_handle){
vTaskDelete(G.fetch_handle);
G.fetch_handle=NULL;
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){
G.dl_cancel_req=true;
vTaskDelay(pdMS_TO_TICKS(100));
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.btn_refresh = 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 });
tt_app_register((AppRegistration){ .onShow=onShow, .onHide=onHide, .onResult=onResult });
return 0;
}
+128 -47
View File
@@ -9,9 +9,13 @@
#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){ return (v<<8)|(v>>8); }
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;
@@ -61,22 +65,43 @@ int http_get_raw(const char* url, char** out_body){
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; memset(&sa,0,sizeof(sa));
sa.sin_family=AF_INET; sa.sin_port=my_htons(port); sa.sin_addr.s_addr=ip;
struct timeval tv={8,0};
lwip_setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&tv,sizeof(tv));
lwip_setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&tv,sizeof(tv));
if(lwip_connect(fd,(struct sockaddr*)&sa,sizeof(sa))<0){ ESP_LOGE(TAG,"connect fail %s:%d",host,port); close(fd); return -1; }
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=65536;
char* buf=heap_caps_malloc(capacity, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if(!buf){ ESP_LOGE(TAG,"PSRAM 64k fail"); close(fd); return -1; }
int 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;
@@ -84,6 +109,13 @@ int http_get_raw(const char* url, char** out_body){
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; }
@@ -95,7 +127,31 @@ int http_get_raw(const char* url, char** out_body){
capacity=newcap;
}
int r=lwip_recv(fd,buf+total,capacity-total-1,0);
if(r<=0) break;
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){
@@ -166,11 +222,14 @@ int http_get_raw(const char* url, char** out_body){
return final_len;
}
void fetch_data(){
bool fetch_data(){
char* js=NULL;
char url[256];
char url[320];
bool seasons_ok=false;
bool episodes_ok=false;
ESP_LOGI(TAG,"fetch seasons");
snprintf(url,sizeof(url),"%s/api/collections/dm_seasons/records?perPage=100&sort=season_num",PB);
// 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);
@@ -186,6 +245,7 @@ void fetch_data(){
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);
}
@@ -198,43 +258,63 @@ void fetch_data(){
G.season_cnt=37;
}
ESP_LOGI(TAG,"fetch episodes");
snprintf(url,sizeof(url),"%s/api/collections/dm_episodes/records?perPage=400&sort=season_num,episode_num",PB);
len=http_get_raw(url,&js);
if(len>0 && js){
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){
cJSON* items=cJSON_GetObjectItem(root,"items");
if(cJSON_IsArray(items)){
G.ep_cnt=0;
int n=cJSON_GetArraySize(items);
for(int i=0;i<n&&G.ep_cnt<MAX_EPS;i++){
cJSON* it=cJSON_GetArrayItem(items,i);
cJSON* t=cJSON_GetObjectItem(it,"title");
cJSON* slug=cJSON_GetObjectItem(it,"slug");
cJSON* sn=cJSON_GetObjectItem(it,"season_num");
cJSON* en=cJSON_GetObjectItem(it,"episode_num");
cJSON* au=cJSON_GetObjectItem(it,"audio_url");
if(!sn||!en) continue;
EpInfo* e=&G.eps[G.ep_cnt];
memset(e,0,sizeof(*e));
if(t&&cJSON_IsString(t)) strncpy(e->title,t->valuestring,sizeof(e->title)-1);
if(slug&&cJSON_IsString(slug)) strncpy(e->slug,slug->valuestring,sizeof(e->slug)-1);
else if(t) strncpy(e->slug,t->valuestring,sizeof(e->slug)-1);
e->season=sn->valueint;
e->ep_num=en->valueint;
if(au&&cJSON_IsString(au)) strncpy(e->audio_url,au->valuestring,sizeof(e->audio_url)-1);
e->seen=has_slug(e->slug);
G.ep_cnt++;
}
sort_eps();
}
cJSON_Delete(root);
if(!root){
ESP_LOGW(TAG,"episodes page %d JSON parse fail",page);
break;
}
} else {
ESP_LOGW(TAG,"episodes fetch failed");
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++){
@@ -246,4 +326,5 @@ void fetch_data(){
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);
}
+2 -2
View File
@@ -15,8 +15,8 @@ void normalize_audio_url(const char* in, char* out, size_t out_len);
// Robust HTTP GET with PSRAM buffer, returns length and allocates *out_body (must be free'd via free/heap_caps_free)
int http_get_raw(const char* url, char** out_body);
// Fetch seasons and episodes from PB
void fetch_data(void);
// Fetch seasons and episodes from PB – returns true if network fetch succeeded with real data
bool fetch_data(void);
#ifdef __cplusplus
}
+180 -219
View File
@@ -1,253 +1,219 @@
#include "player.h"
#include "storage.h"
#include "download.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 <lwip/sockets.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include <unistd.h>
#include "esp_heap_caps.h"
#define MINIMP3_IMPLEMENTATION
#define MINIMP3_NO_SIMD
#include "minimp3.h"
bool find_audio_device(){
struct Device* dev=device_find_by_name("audio-stream");
if(dev){ G.stream_dev=dev; return true; }
dev=device_find_first_by_type(&AUDIO_STREAM_TYPE);
if(dev){ G.stream_dev=dev; return true; }
return false;
}
void close_stream(){
if(G.stream_handle){
audio_stream_close(G.stream_handle);
G.stream_handle=NULL;
}
}
bool open_stream(uint32_t sr, uint8_t ch){
close_stream();
if(!G.stream_dev && !find_audio_device()) return false;
struct AudioStreamConfig cfg={ .sample_rate=sr, .bits_per_sample=16, .channels=ch };
error_t err=audio_stream_open_output(G.stream_dev,&cfg,&G.stream_handle);
return err==ERROR_NONE;
void wait_play_exit(void) {
// No internal playback anymore, nothing to wait
}
void wait_play_exit(){
if(G.play_handle){
G.need_stop=true;
while(G.play_handle!=NULL){
tt_lvgl_unlock();
vTaskDelay(pdMS_TO_TICKS(10));
tt_lvgl_lock(portMAX_DELAY);
}
}
close_stream();
void stop_playback_sync(void) {
// No internal playback, nothing to stop
G.is_playing = false;
G.is_paused = false;
}
void stop_playback_sync(){
if(G.play_handle){
G.need_stop=true;
int tries=200;
while(G.play_handle!=NULL && tries-- >0){
vTaskDelay(pdMS_TO_TICKS(10));
}
}
close_stream();
G.is_playing=false;
G.is_paused=false;
}
void play_task(void* arg){
(void)arg;
int idx=G.cur_ep_idx;
if(idx<0||idx>=G.ep_cnt){ G.play_handle=NULL; vTaskDelete(NULL); return; }
static bool try_external_downloader(int idx) {
if(idx<0||idx>=G.ep_cnt) return false;
EpInfo* ep=&G.eps[idx];
char path[300];
make_sd_path(path,sizeof(path),ep->slug);
FILE* file=fopen(path,"rb");
if(!file){ ESP_LOGE(TAG,"open fail %s",path); G.is_playing=false; G.play_handle=NULL; vTaskDelete(NULL); return; }
fseek(file,0,SEEK_END);
long fsize=ftell(file);
fseek(file,0,SEEK_SET);
if(fsize>0) G.total_sec=(int)(fsize/16000);
if(!find_audio_device()){ ESP_LOGE(TAG,"no audio-stream dev"); fclose(file); G.is_playing=false; G.play_handle=NULL; vTaskDelete(NULL); return; }
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);
uint8_t id3hdr[10];
long id3_skip=0;
if(fread(id3hdr,1,10,file)==10 && memcmp(id3hdr,"ID3",3)==0){
int id3size = (id3hdr[6]&0x7F)<<21 | (id3hdr[7]&0x7F)<<14 | (id3hdr[8]&0x7F)<<7 | (id3hdr[9]&0x7F);
ESP_LOGI(TAG,"ID3 found %d, skip",id3size);
id3_skip=id3size+10;
fseek(file, id3_skip, SEEK_SET);
fsize -= id3_skip;
if(fsize>0) G.total_sec=(int)(fsize/16000);
} else {
fseek(file,0,SEEK_SET);
}
// Preserve browsing season so we return to same season after downloader closes – survive G memset via static
dm_preserve_season(ep->season);
int saved_season = G.cur_season;
G.cur_season = ep->season;
save_state();
G.cur_ep_idx = idx;
(void)saved_season;
int resume_sec = G.pos_sec;
if(resume_sec>5){
long seek_bytes = (long)resume_sec * 16000;
if(seek_bytes < fsize){
fseek(file, id3_skip + seek_bytes, SEEK_SET);
ESP_LOGI(TAG,"Resuming %s from %d sec (~%ld bytes)", ep->slug, resume_sec, seek_bytes);
}
}
tt_lvgl_lock(portMAX_DELAY);
close_overlay();
if(G.dl_overlay) hide_dl_overlay();
tt_lvgl_unlock();
mp3dec_t* dec = heap_caps_malloc(sizeof(mp3dec_t), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if(!dec){ ESP_LOGE(TAG,"dec malloc fail"); fclose(file); G.play_handle=NULL; vTaskDelete(NULL); return; }
mp3dec_init(dec);
uint8_t* inbuf=malloc(MP3_BUF);
int16_t* pcm=malloc(1152*2*2);
if(!inbuf||!pcm){ if(inbuf) free(inbuf); if(pcm) free(pcm); fclose(file); heap_caps_free(dec); G.play_handle=NULL; vTaskDelete(NULL); return; }
size_t buffered=0;
mp3dec_frame_info_t info;
memset(&info,0,sizeof(info));
bool configured=false;
int sr=0, ch=0;
size_t r=fread(inbuf,1,MP3_BUF,file);
buffered=r;
ESP_LOGI(TAG,"play task start %s size %ld resume=%d",path,fsize,resume_sec);
long total_decoded_samples = 0;
if(resume_sec>5){
total_decoded_samples = (long)resume_sec * 16000;
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);
while(!G.need_stop){
if(G.is_paused){
if(configured){
close_stream();
configured=false;
sr=0; ch=0;
}
vTaskDelay(pdMS_TO_TICKS(100));
continue;
}
int samples=mp3dec_decode_frame(dec,inbuf,(int)buffered,pcm,&info);
if(samples>0){
total_decoded_samples += samples;
if(info.hz>0) G.pos_sec = total_decoded_samples / info.hz;
else G.pos_sec = total_decoded_samples / 16000;
if(!configured || info.hz!=sr || info.channels!=ch){
ESP_LOGI(TAG,"open_stream sr=%d ch=%d",info.hz,info.channels);
if(!open_stream((uint32_t)info.hz,(uint8_t)info.channels)){
ESP_LOGE(TAG,"open_stream fail %d %d",info.hz,info.channels);
break;
}
ESP_LOGI(TAG,"open_stream ok");
sr=info.hz; ch=info.channels;
configured=true;
if(resume_sec>5 && total_decoded_samples < (long)resume_sec * info.hz + info.hz){
total_decoded_samples = (long)resume_sec * info.hz;
G.pos_sec = resume_sec;
}
}
float vol=G.volume/100.0f;
if(vol<0) vol=0;
if(vol>1) vol=1;
int total_samples = samples * (info.channels>0?info.channels:1);
for(int i=0;i<total_samples;i++){
int32_t s=pcm[i];
s=(int32_t)(s*vol);
if(s>32767) s=32767;
if(s<-32768) s=-32768;
pcm[i]=(int16_t)s;
}
size_t to_write = total_samples * sizeof(int16_t);
size_t off=0;
while(off<to_write && !G.need_stop){
if(G.is_paused){
if(configured){
close_stream();
configured=false;
sr=0; ch=0;
}
break;
}
size_t w=0;
error_t e=audio_stream_write(G.stream_handle,(uint8_t*)pcm+off,to_write-off,&w,pdMS_TO_TICKS(500));
if(e==ERROR_NONE){
off+=w;
} else {
ESP_LOGW(TAG,"audio_write err %d",e);
vTaskDelay(pdMS_TO_TICKS(10));
}
}
if(G.is_paused) continue;
}
if(info.frame_bytes>0 && info.frame_bytes <= (int)buffered){
memmove(inbuf,inbuf+info.frame_bytes,buffered-info.frame_bytes);
buffered-=info.frame_bytes;
} else {
buffered=0;
}
if(buffered < MP3_BUF/2){
size_t nr=fread(inbuf+buffered,1,MP3_BUF-buffered,file);
if(nr>0) buffered+=nr;
else { if(samples==0) break; }
}
if(buffered==0) break;
ESP_LOGI(TAG, "Starting external SdDownloader for %s -> %s season %d", ep->slug, fpath, G.cur_season);
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;
}
int count_missing_in_season(int season){
int cnt=0;
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) cnt++;
}
fclose(file);
free(inbuf);
free(pcm);
heap_caps_free(dec);
close_stream();
bool finished_naturally = !G.need_stop;
if(finished_naturally){
ESP_LOGI(TAG,"playback finished naturally for %s, clearing saved pos", ep->slug);
set_saved_pos_for_slug(ep->slug, 0);
G.pos_sec=0;
save_state();
} else {
set_saved_pos_for_slug(ep->slug, G.pos_sec);
save_state();
return cnt;
}
int find_next_missing_in_season(int season){
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) return i;
}
ESP_LOGI(TAG,"play task end finished_naturally=%d", finished_naturally);
G.is_playing=false;
G.play_handle=NULL;
vTaskDelete(NULL);
return -1;
}
bool download_episode(int idx){
return try_external_downloader(idx);
}
bool download_missing_next(void){
if(!G.dl_missing_active) return false;
int next = find_next_missing_in_season(G.dl_missing_season);
if(next==-1){
// No more missing – done
G.dl_missing_active=false;
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status){
char msg[64];
snprintf(msg,sizeof(msg),"DL S%02d done %d ok %d fail", G.dl_missing_season, G.dl_missing_done, G.dl_missing_failed);
lv_label_set_text(G.lbl_status, msg);
}
tt_lvgl_unlock();
return false;
}
// Update UI for next file
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status){
char msg[64];
snprintf(msg,sizeof(msg),"DL %d/%d S%02d...", G.dl_missing_done+1, G.dl_missing_total, G.dl_missing_season);
lv_label_set_text(G.lbl_status, msg);
}
tt_lvgl_unlock();
return try_external_downloader(next);
}
bool download_missing_season_start(int season){
int missing = count_missing_in_season(season);
if(missing==0){
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status) lv_label_set_text(G.lbl_status,"Season already cached");
tt_lvgl_unlock();
G.dl_missing_active=false;
return true;
}
ESP_LOGI(TAG, "Starting sequential DL for S%02d, %d missing", season, missing);
G.dl_missing_active=true;
G.dl_missing_season=season;
G.dl_missing_total=missing;
G.dl_missing_done=0;
G.dl_missing_failed=0;
// Preserve season in persisted state and static before leaving
dm_preserve_season(season);
G.cur_season = season;
save_state();
int first = find_next_missing_in_season(season);
if(first==-1){
G.dl_missing_active=false;
return true;
}
// Update status
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status){
char msg[64];
snprintf(msg,sizeof(msg),"DL 1/%d S%02d...", missing, season);
lv_label_set_text(G.lbl_status, msg);
}
tt_lvgl_unlock();
return try_external_downloader(first);
}
static bool try_external_downloader_batch(int season) {
// Sequential queue – safe single-file mode
return download_missing_season_start(season);
}
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){
int saved = get_saved_pos_for_slug(ep->slug);
saved = get_saved_pos_for_slug(ep->slug);
if(saved>5){
G.pos_sec=saved;
ESP_LOGI(TAG,"Restoring saved pos %d for %s", saved, ep->slug);
}
G.last_pct=-1;
} else {
saved = G.pos_sec;
}
tt_lvgl_lock(portMAX_DELAY);
wait_play_exit();
G.cur_ep_idx=idx; G.cur_season=ep->season;
strncpy(G.cur_title,ep->title,sizeof(G.cur_title)-1);
strncpy(G.last_slug,ep->slug,sizeof(G.last_slug)-1);
G.need_stop=false; G.is_paused=false; G.is_playing=true;
save_state();
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 season %d", fpath, saved, ep->season);
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);
dm_preserve_season(ep->season);
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();
xTaskCreate(play_task,"dm_play",16384,NULL,5,&G.play_handle);
}
void start_idx(int idx){
if(idx<0||idx>=G.ep_cnt) return;
EpInfo* ep=&G.eps[idx];
if(!has_slug(ep->slug)){
stop_playback_sync();
// 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);
show_dl_overlay_locked(ep->title, ep->slug, ep->season, ep->ep_num);
if(G.lbl_status) lv_label_set_text(G.lbl_status,"Downloading…");
if(G.lbl_status) lv_label_set_text(G.lbl_status,"Install SdDownloader");
tt_lvgl_unlock();
if(G.dl_handle){ return; }
xTaskCreate(dl_task,"dm_dl",16384,(void*)(intptr_t)idx,5,&G.dl_handle);
return;
}
start_idx_internal(idx);
@@ -264,12 +230,7 @@ void select_ep(int idx){
strncpy(G.last_slug, ep->slug, sizeof(G.last_slug)-1);
int saved = get_saved_pos_for_slug(ep->slug);
if(saved>5){
G.pos_sec=saved;
ESP_LOGI(TAG,"select_ep restore %s pos=%d", ep->slug, saved);
} else {
G.pos_sec=saved;
}
G.pos_sec=saved;
G.last_pct=-1;
G.total_sec=0;
@@ -312,33 +273,33 @@ void go_next(){
if(G.ep_cnt==0) return;
save_current_ep_pos();
save_state();
stop_playback_sync();
int n=G.cur_ep_idx+1;
if(n>=G.ep_cnt) n=0;
select_ep(n);
start_idx(n);
// Do not auto-play next, per user request: no autoplay
}
void go_prev(){
if(G.ep_cnt==0) return;
if(G.pos_sec>5){
ESP_LOGI(TAG,"go_prev restart current %d sec",G.pos_sec);
if(G.cur_ep_idx>=0){
set_saved_pos_for_slug(G.eps[G.cur_ep_idx].slug, 0);
save_state();
}
stop_playback_sync();
G.pos_sec=0;
int cur=G.cur_ep_idx;
if(cur<0) cur=0;
select_ep(cur);
start_idx(cur);
return;
}
save_current_ep_pos();
save_state();
stop_playback_sync();
int p=G.cur_ep_idx-1;
if(p<0) p=G.ep_cnt-1;
select_ep(p);
start_idx(p);
}
// Called from UI for season batch download
bool download_season(int season) {
return try_external_downloader_batch(season);
}
+9 -4
View File
@@ -7,18 +7,23 @@
extern "C" {
#endif
bool find_audio_device(void);
void close_stream(void);
bool open_stream(uint32_t sr, uint8_t ch);
// Simplified: no internal audio, only external apps
void wait_play_exit(void);
void stop_playback_sync(void);
void play_task(void* arg);
void start_idx_internal(int idx);
void start_idx(int idx);
void select_ep(int idx);
void go_next(void);
void go_prev(void);
bool download_season(int season);
// New single-file download API
bool download_episode(int idx);
bool download_missing_season_start(int season);
bool download_missing_next(void); // continue queue, returns false if done
int count_missing_in_season(int season);
int find_next_missing_in_season(int season);
#ifdef __cplusplus
}
+124 -5
View File
@@ -6,6 +6,7 @@
#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;
@@ -36,6 +37,9 @@ void sort_seasons(){
}
}
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];
@@ -43,10 +47,11 @@ void sort_eps(){
if(a->season != b->season) should_swap = (b->season < a->season);
else should_swap = (b->ep_num < a->ep_num);
if(should_swap){
EpInfo t=*a; *a=*b; *b=t;
*t=*a; *a=*b; *b=*t;
}
}
}
heap_caps_free(t);
}
// ── Per-episode position helpers ──
@@ -146,7 +151,8 @@ void load_state(){
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; }
// Do NOT overwrite cur_season here based on slug – browsing season is independent
// cur_ep_idx will be resolved after cache load in onShow
}
cJSON* poss=cJSON_GetObjectItem(r,"positions");
if(poss && cJSON_IsObject(poss)){
@@ -166,12 +172,124 @@ void load_state(){
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;
FE files[200];
// 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) return;
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){
@@ -192,7 +310,7 @@ void lru_check(){
fc++;
}
closedir(d);
if(fc<12) return;
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; }
@@ -200,4 +318,5 @@ void lru_check(){
}
int todel=fc-8;
for(int i=0;i<todel;i++) unlink(files[i].path);
heap_caps_free(files);
}
@@ -33,6 +33,10 @@ void save_current_ep_pos(void);
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
+507 -164
View File
@@ -4,16 +4,274 @@
#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){
// Keep showing current season stats instead of blocking header
// status bar already says fetching
}
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.ep_cnt==0){
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;
}
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) return;
// Allow interaction even while fetching – cache remains usable
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();
}
}
@@ -23,7 +281,7 @@ void dropdown_cb(lv_event_t* e){
int sel=lv_dropdown_get_selected(dd);
if(sel<0||sel>=G.season_cnt) return;
int sn=G.seasons[sel].num;
close_overlay();
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;
@@ -32,213 +290,298 @@ void dropdown_cb(lv_event_t* e){
int tgt=first_unseen!=-1?first_unseen:first;
if(tgt!=-1){
G.cur_season=sn;
select_ep(tgt);
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_refresh_cb(lv_event_t* e){
(void)e;
if(G.downloading) return;
if(G.overlay){ close_overlay(); }
if(G.fetching){
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status) lv_label_set_text(G.lbl_status,"Already refreshing...");
tt_lvgl_unlock();
return;
}
ESP_LOGI(TAG,"Manual refresh pressed");
trigger_manual_refresh();
}
void btn_seasons_cb(lv_event_t* e){
(void)e;
if(G.downloading) return;
if(G.overlay){ close_overlay(); return; }
tt_lvgl_lock(portMAX_DELAY);
G.overlay=lv_obj_create(lv_scr_act());
lv_obj_set_size(G.overlay,240,280);
lv_obj_center(G.overlay);
lv_obj_set_style_bg_color(G.overlay,lv_color_hex(0x222222),0);
lv_obj_set_style_radius(G.overlay,12,0);
lv_obj_t* title=lv_label_create(G.overlay);
// Full-screen season selector – parent to active screen to cover whole 240x320, not just root content
lv_obj_t* ovl_parent = lv_scr_act();
G.overlay=lv_obj_create(ovl_parent);
lv_obj_set_size(G.overlay, LV_PCT(100), LV_PCT(100));
lv_obj_set_pos(G.overlay, 0, 0);
lv_obj_set_style_bg_color(G.overlay, lv_color_hex(0x111111),0);
lv_obj_set_style_bg_opa(G.overlay, LV_OPA_COVER,0);
lv_obj_set_style_radius(G.overlay,0,0);
lv_obj_set_style_border_width(G.overlay,0,0);
lv_obj_set_style_pad_all(G.overlay,6,0);
lv_obj_set_style_pad_gap(G.overlay,6,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_START, LV_FLEX_ALIGN_START);
lv_obj_remove_flag(G.overlay, LV_OBJ_FLAG_SCROLLABLE);
// Header row: title + close
lv_obj_t* hdr=lv_obj_create(G.overlay);
lv_obj_set_size(hdr, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_bg_opa(hdr, LV_OPA_TRANSP,0);
lv_obj_set_style_border_width(hdr,0,0);
lv_obj_set_style_pad_all(hdr,2,0);
lv_obj_set_flex_flow(hdr, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(hdr, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_remove_flag(hdr, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* title=lv_label_create(hdr);
lv_label_set_text(title,"Select Season");
lv_obj_align(title,LV_ALIGN_TOP_MID,0,8);
lv_obj_set_style_text_font(title, lvgl_get_text_font(FONT_SIZE_DEFAULT),0);
lv_obj_set_style_text_color(title, lv_color_hex(0xFFFFFF),0);
lv_obj_set_flex_grow(title,1);
static char opts[1024];
opts[0]=0;
for(int i=0;i<G.season_cnt;i++){
char line[16];
if(i==0) snprintf(opts,sizeof(opts),"S%d",G.seasons[i].num);
else { snprintf(line,sizeof(line),"\nS%d",G.seasons[i].num); strncat(opts,line,sizeof(opts)-strlen(opts)-1); }
}
G.dropdown=lv_dropdown_create(G.overlay);
lv_dropdown_set_options(G.dropdown, opts);
lv_obj_set_size(G.dropdown,160,40);
lv_obj_align(G.dropdown,LV_ALIGN_CENTER,0,10);
int sel=0;
for(int i=0;i<G.season_cnt;i++) if(G.seasons[i].num==G.cur_season) sel=i;
lv_dropdown_set_selected(G.dropdown, sel);
lv_obj_add_event_cb(G.dropdown,dropdown_cb,LV_EVENT_VALUE_CHANGED,NULL);
lv_obj_t* bc=lv_btn_create(G.overlay);
lv_obj_set_size(bc,80,32);
lv_obj_align(bc,LV_ALIGN_BOTTOM_MID,0,-8);
lv_obj_t* bc=lv_btn_create(hdr);
lv_obj_set_size(bc, 40, 36);
lv_obj_set_style_bg_color(bc, lv_color_hex(0x333333),0);
lv_obj_set_style_radius(bc,6,0);
lv_obj_t* lc=lv_label_create(bc);
lv_label_set_text(lc,"Close");
lv_label_set_text(lc, LV_SYMBOL_CLOSE);
lv_obj_set_style_text_font(lc, lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_center(lc);
lv_obj_add_event_cb(bc,(lv_event_cb_t)close_overlay,LV_EVENT_CLICKED,NULL);
lv_obj_add_event_cb(bc,close_btn_cb,LV_EVENT_CLICKED,NULL);
// Scrollable list container fills rest
G.season_list_cont=lv_obj_create(G.overlay);
lv_obj_set_size(G.season_list_cont, LV_PCT(100), LV_PCT(100));
lv_obj_set_flex_grow(G.season_list_cont,1);
lv_obj_set_style_bg_color(G.season_list_cont, lv_color_hex(0x0E0E0E),0);
lv_obj_set_style_radius(G.season_list_cont,8,0);
lv_obj_set_style_pad_all(G.season_list_cont,6,0);
lv_obj_set_flex_flow(G.season_list_cont, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_gap(G.season_list_cont,6,0);
lv_obj_add_flag(G.season_list_cont, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_scrollbar_mode(G.season_list_cont, LV_SCROLLBAR_MODE_AUTO);
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), 52);
lv_obj_set_style_bg_color(btn, sn==G.cur_season ? lv_color_hex(0x2E7D32) : lv_color_hex(0x222222),0);
lv_obj_set_style_radius(btn,8,0);
lv_obj_set_style_pad_all(btn,10,0);
lv_obj_set_style_border_width(btn,1,0);
lv_obj_set_style_border_color(btn, sn==G.cur_season ? lv_color_hex(0x4CAF50) : lv_color_hex(0x333333),0);
lv_obj_add_event_cb(btn, season_select_cb, LV_EVENT_CLICKED, (void*)(intptr_t)sn);
lv_obj_set_flex_flow(btn, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(btn, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
char blabel[48];
if(total>0){
snprintf(blabel,sizeof(blabel),"Season %02d %d/%d on SD", sn, cached, total);
}else{
snprintf(blabel,sizeof(blabel),"Season %02d %d eps", sn, total);
}
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_DEFAULT),0);
lv_obj_set_flex_grow(l1,1);
if(sn==G.cur_season){
lv_obj_t* lcheck=lv_label_create(btn);
lv_label_set_text(lcheck, LV_SYMBOL_OK);
lv_obj_set_style_text_color(lcheck, lv_color_hex(0x4CAF50),0);
}
}
tt_lvgl_unlock();
}
void build_ui(){
void btn_dl_season_cb(lv_event_t* e){
(void)e;
if(G.downloading) 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);
lv_obj_t* root=lv_obj_create(lv_scr_act());
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,8,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);
G.lbl_title=lv_label_create(root);
// 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_width(G.lbl_title,LV_PCT(100));
lv_label_set_long_mode(G.lbl_title,LV_LABEL_LONG_WRAP);
lv_obj_align(G.lbl_title,LV_ALIGN_TOP_LEFT,0,0);
lv_obj_set_style_text_color(G.lbl_title, lv_color_hex(0xFFFFFF),0);
G.lbl_meta=lv_label_create(root);
lv_label_set_text(G.lbl_meta,"Loading...");
lv_obj_set_style_text_color(G.lbl_meta,lv_color_hex(0xAAAAAA),0);
lv_obj_align(G.lbl_meta,LV_ALIGN_TOP_LEFT,0,28);
G.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);
G.bar=lv_bar_create(root);
lv_obj_set_size(G.bar,LV_PCT(100),12);
lv_obj_align(G.bar,LV_ALIGN_TOP_LEFT,0,50);
lv_bar_set_value(G.bar,0,LV_ANIM_OFF);
G.lbl_time=lv_label_create(root);
lv_label_set_text(G.lbl_time,"0:00 / --:--");
lv_obj_align(G.lbl_time,LV_ALIGN_TOP_LEFT,0,66);
G.lbl_status=lv_label_create(root);
lv_label_set_text(G.lbl_status,"Fetching data...");
lv_obj_set_style_text_color(G.lbl_status,lv_color_hex(0x888888),0);
lv_obj_align(G.lbl_status,LV_ALIGN_TOP_LEFT,0,82);
// 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 + Refresh (DL Missing removed per UX simplification)
lv_obj_t* row=lv_obj_create(root);
lv_obj_set_size(row,LV_PCT(100),56);
lv_obj_align(row,LV_ALIGN_TOP_LEFT,0,104);
lv_obj_set_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* b1=lv_btn_create(row);
lv_obj_set_size(b1,56,48);
lv_obj_t* l1=lv_label_create(b1);
lv_label_set_text(l1,LV_SYMBOL_PREV);
lv_obj_center(l1);
lv_obj_add_event_cb(b1,btn_prev_cb,LV_EVENT_CLICKED,NULL);
G.btn_play=lv_btn_create(row);
lv_obj_set_size(G.btn_play,72,48);
lv_obj_set_style_bg_color(G.btn_play,lv_color_hex(0x2E7D32),0);
lv_obj_t* l2=lv_label_create(G.btn_play);
lv_label_set_text(l2,LV_SYMBOL_PLAY);
lv_obj_center(l2);
lv_obj_add_event_cb(G.btn_play,btn_play_cb,LV_EVENT_CLICKED,NULL);
lv_obj_t* b3=lv_btn_create(row);
lv_obj_set_size(b3,56,48);
lv_obj_t* l3=lv_label_create(b3);
lv_label_set_text(l3,LV_SYMBOL_NEXT);
lv_obj_center(l3);
lv_obj_add_event_cb(b3,btn_next_cb,LV_EVENT_CLICKED,NULL);
lv_obj_t* row2=lv_obj_create(root);
lv_obj_set_size(row2,LV_PCT(100),44);
lv_obj_align(row2,LV_ALIGN_BOTTOM_LEFT,0,0);
lv_obj_set_flex_flow(row2,LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(row2,LV_FLEX_ALIGN_SPACE_EVENLY,LV_FLEX_ALIGN_CENTER,LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_bg_opa(row2,LV_OPA_TRANSP,0);
lv_obj_set_style_border_width(row2,0,0);
lv_obj_remove_flag(row2, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* bs=lv_btn_create(row2);
lv_obj_set_size(bs,LV_PCT(100),36);
lv_obj_t* 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_timer_create(ui_timer,200,NULL);
lv_obj_t* br=lv_btn_create(row);
lv_obj_set_size(br,LV_PCT(48),36);
G.btn_refresh=br;
lv_obj_set_style_bg_color(br, lv_color_hex(0x444444), 0);
lv_obj_set_style_radius(br,6,0);
lv_obj_t* lr=lv_label_create(br);
lv_label_set_text(lr, LV_SYMBOL_REFRESH " Refresh");
lv_obj_set_style_text_font(lr, lvgl_get_text_font(FONT_SIZE_SMALL),0);
lv_obj_center(lr);
lv_obj_add_event_cb(br,btn_refresh_cb,LV_EVENT_CLICKED,NULL);
G.btn_dl_missing=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 removed per UX request – no help label
G.lbl_status=NULL;
// 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(G.downloading){
// 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();
}
if(G.bar){
if(G.total_sec>0){
int pct=G.pos_sec*100/G.total_sec;
if(pct>100) pct=100;
if(pct!=G.last_pct){
G.last_pct=pct;
lv_bar_set_value(G.bar,pct,LV_ANIM_OFF);
}
} else if(G.is_playing){
int pct=(G.pos_sec%10)*10;
if(pct!=G.last_pct){
G.last_pct=pct;
lv_bar_set_value(G.bar,pct,LV_ANIM_OFF);
}
}
}
if(G.lbl_time){
char a[16],b[16];
fmt_time(a,G.pos_sec);
if(G.total_sec>0) fmt_time(b,G.total_sec);
else snprintf(b,sizeof(b),"--:--");
char buf[40];
snprintf(buf,sizeof(buf),"%s / %s",a,b);
lv_label_set_text(G.lbl_time,buf);
}
if(G.is_playing && G.lbl_status && !G.downloading){
const char* cur = lv_label_get_text(G.lbl_status);
if(!cur || strcmp(cur,"Playing")!=0) lv_label_set_text(G.lbl_status,"Playing");
}
if(G.is_playing && !G.is_paused && !G.downloading){
G.last_save_tick++;
if(G.last_save_tick >= 25){
G.last_save_tick=0;
save_current_ep_pos();
save_state();
}
}
}
void btn_play_cb(lv_event_t* e){
(void)e;
if(G.downloading){
return;
}
if(G.is_playing && !G.is_paused){
G.is_paused=true;
save_current_ep_pos();
save_state();
tt_lvgl_lock(portMAX_DELAY);
if(G.lbl_status) lv_label_set_text(G.lbl_status,"Paused");
if(G.btn_play){
lv_obj_t* ch=lv_obj_get_child(G.btn_play,0);
if(ch) lv_label_set_text(ch,LV_SYMBOL_PLAY);
}
tt_lvgl_unlock();
} else if(G.is_paused){
G.is_paused=false;
tt_lvgl_lock(portMAX_DELAY);
if(G.btn_play){
lv_obj_t* ch=lv_obj_get_child(G.btn_play,0);
if(ch) lv_label_set_text(ch,LV_SYMBOL_PAUSE);
}
tt_lvgl_unlock();
} else {
start_idx(G.cur_ep_idx);
tt_lvgl_lock(portMAX_DELAY);
if(G.btn_play){
lv_obj_t* ch=lv_obj_get_child(G.btn_play,0);
if(ch) lv_label_set_text(ch,LV_SYMBOL_PAUSE);
}
tt_lvgl_unlock();
}
}
void btn_prev_cb(lv_event_t* e){ (void)e; if(G.downloading) return; go_prev(); }
void btn_next_cb(lv_event_t* e){ (void)e; if(G.downloading) return; go_next(); }
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 */ }
+9 -1
View File
@@ -6,16 +6,24 @@
extern "C" {
#endif
void build_ui(void);
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 btn_refresh_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
+16
View File
@@ -0,0 +1,16 @@
cmake_minimum_required(VERSION 3.20)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
if (DEFINED ENV{TACTILITY_SDK_PATH})
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
else()
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
message(WARNING "⚠️ TACTILITY_SDK_PATH environment variable is not set, defaulting to ${TACTILITY_SDK_PATH}")
endif()
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
project(ImmichElias)
tactility_project(ImmichElias)
+9
View File
@@ -0,0 +1,9 @@
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
set(CJSON_SOURCE "$ENV{IDF_PATH}/components/json/cJSON/cJSON.c")
idf_component_register(
SRCS ${SOURCE_FILES} ${CJSON_SOURCE}
INCLUDE_DIRS Source "$ENV{IDF_PATH}/components/json/cJSON"
REQUIRES TactilitySDK esp_http_client
)
target_compile_options(${COMPONENT_LIB} PRIVATE -Wno-format-truncation -Wno-unused-but-set-variable -Wno-unused-function -Wno-unused-variable)
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
manifest.version=0.2
target.sdk=0.8.0-dev
target.platforms=esp32s3,esp32p4
app.id=one.tactility.imichelias
app.version.name=0.1.0
app.version.code=1
app.name=Immich Elias
+2 -1
View File
@@ -2,5 +2,6 @@ file(GLOB_RECURSE SOURCE_FILES Source/*.c)
idf_component_register(
SRCS ${SOURCE_FILES}
REQUIRES TactilitySDK
REQUIRES TactilitySDK esp_http_client
)
target_compile_options(${COMPONENT_LIB} PRIVATE -Wno-deprecated-declarations -Wno-unused-but-set-variable -Wno-format-truncation)
File diff suppressed because it is too large Load Diff
+12
View File
@@ -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)
+6
View File
@@ -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
)
+460
View File
@@ -0,0 +1,460 @@
#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;
size_t free_internal = heap_caps_get_free_size(MALLOC_CAP_INTERNAL);
BundleHandle result_bundle = NULL;
if (free_internal > 8000) {
result_bundle = tt_bundle_alloc();
}
if (result_bundle) {
tt_bundle_put_bool(result_bundle, "success", success);
// Keep bundle minimal to avoid low heap crash (previous put_string path 512B caused OOM)
tt_app_set_result(G.app_handle, success ? APP_RESULT_OK : APP_RESULT_ERROR, result_bundle);
} else {
// Low heap – return OK without bundle (DM will still refresh via has_slug)
if (free_internal <= 8000) {
ESP_LOGW(TAG, "Low heap %d, returning result without bundle", free_internal);
}
tt_app_set_result(G.app_handle, success ? APP_RESULT_OK : APP_RESULT_ERROR, NULL);
}
// Change Cancel button to Close once done (manual close, auto-close unreliable)
tt_lvgl_lock(portMAX_DELAY);
if (G.btn_cancel) {
lv_obj_t* lbl = lv_obj_get_child(G.btn_cancel, 0);
if (lbl) lv_label_set_text(lbl, "Close");
}
tt_lvgl_unlock();
}
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 (returning true as cached)", path);
G.total = st.st_size;
update_ui_status("Already on SD");
return true;
} 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;
}
+7
View File
@@ -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
+123 -14
View File
@@ -30,6 +30,18 @@
#define CHUNK_BYTES (CHUNK_SAMPLES * (BITS_PER_SAMPLE / 8)) // 1024 bytes
#define AUDIO_BUF_SIZE 4096
// ─── Scroll animation flags ───
// LVGL label scroll animation (LV_LABEL_LONG_SCROLL_*) causes continuous
// invalidations and screen refreshes that can starve the audio_stream task.
// LVGL's lv_list_add_button() internally sets SCROLL_CIRCULAR by default (see
// lv_list.c), so we must override it for memo names.
//
// User request: apply flag only to recordings names on the list.
#define ENABLE_SCROLL_ANIM_WHEN_IDLE 1
#define ENABLE_SCROLL_ANIM_DURING_AUDIO 0
#define ENABLE_MEMO_LIST_SCROLL_ANIM 0 // 0 = DOT truncation (no anim, safe for audio), 1 = SCROLL_CIRCULAR
#define ENABLE_MEMO_LIST_SCROLL_DURING_AUDIO 0 // Force DOT during recording/playback regardless of above
#define MAX_MEMOS 50
#define MAX_FILENAME_LEN 64
@@ -208,6 +220,8 @@ static void record_task(void* arg) {
size_t total_written = 0;
uint32_t start_time = xTaskGetTickCount();
uint32_t last_ui_sec = 0;
bool first_ui = true;
while (ctx->state == STATE_RECORDING) {
size_t bytes_read = 0;
@@ -223,13 +237,18 @@ static void record_task(void* arg) {
}
uint32_t elapsed_sec = (xTaskGetTickCount() - start_time) / configTICK_RATE_HZ;
char status_buf[64];
snprintf(status_buf, sizeof(status_buf), "🔴 Recording... %02u:%02u", (unsigned)(elapsed_sec / 60), (unsigned)(elapsed_sec % 60));
// Throttle UI updates to once per second to reduce LVGL invalidations during recording
if (first_ui || elapsed_sec != last_ui_sec) {
first_ui = false;
last_ui_sec = elapsed_sec;
char status_buf[64];
snprintf(status_buf, sizeof(status_buf), "🔴 Recording... %02u:%02u", (unsigned)(elapsed_sec / 60), (unsigned)(elapsed_sec % 60));
tt_lvgl_lock(portMAX_DELAY);
lv_label_set_text(ctx->lbl_status, status_buf);
lv_bar_set_value(ctx->bar_progress, (int)((elapsed_sec * 100 / 300)) % 100, LV_ANIM_OFF);
tt_lvgl_unlock();
tt_lvgl_lock(portMAX_DELAY);
lv_label_set_text(ctx->lbl_status, status_buf);
lv_bar_set_value(ctx->bar_progress, (int)((elapsed_sec * 100 / 300)) % 100, LV_ANIM_OFF);
tt_lvgl_unlock();
}
}
audio_stream_close(ctx->input_handle);
@@ -349,6 +368,9 @@ static void play_task(void* arg) {
}
bool out_open = true;
int last_pct = -1;
uint32_t last_elapsed_sec = 0;
bool first_ui = true;
while (total_played < data_size && ctx->state != STATE_IDLE) {
if (ctx->state == STATE_PAUSED) {
@@ -407,15 +429,21 @@ static void play_task(void* arg) {
uint32_t elapsed_sec = total_played / bytes_per_sec;
uint32_t total_sec = data_size / bytes_per_sec;
char status_buf[64];
snprintf(status_buf, sizeof(status_buf), "🔊 Playing... %02u:%02u / %02u:%02u",
(unsigned)(elapsed_sec / 60), (unsigned)(elapsed_sec % 60),
(unsigned)(total_sec / 60), (unsigned)(total_sec % 60));
// Throttle UI updates – only when % or second changes, to reduce LVGL work during audio
if (first_ui || pct != last_pct || elapsed_sec != last_elapsed_sec) {
first_ui = false;
last_pct = pct;
last_elapsed_sec = elapsed_sec;
char status_buf[64];
snprintf(status_buf, sizeof(status_buf), "🔊 Playing... %02u:%02u / %02u:%02u",
(unsigned)(elapsed_sec / 60), (unsigned)(elapsed_sec % 60),
(unsigned)(total_sec / 60), (unsigned)(total_sec % 60));
tt_lvgl_lock(portMAX_DELAY);
lv_label_set_text(ctx->lbl_status, status_buf);
lv_bar_set_value(ctx->bar_progress, pct, LV_ANIM_OFF);
tt_lvgl_unlock();
tt_lvgl_lock(portMAX_DELAY);
lv_label_set_text(ctx->lbl_status, status_buf);
lv_bar_set_value(ctx->bar_progress, pct, LV_ANIM_OFF);
tt_lvgl_unlock();
}
}
fclose(f);
@@ -549,17 +577,91 @@ static void refresh_memo_list(AppCtx* ctx) {
for (int i = 0; i < ctx->memo_count; i++) {
lv_obj_t* btn = lv_list_add_btn(ctx->lst_memos, LV_SYMBOL_AUDIO, ctx->memos[i]);
lv_obj_add_event_cb(btn, on_memo_selected, LV_EVENT_CLICKED, ctx);
// ─── Fix: LVGL list buttons default to SCROLL_CIRCULAR (see lv_list.c)
// This causes constant invalidations that starve audio_stream.
// User wants flag to disable this for recording names.
// Safe handling: last child is always the label (icon is first if present)
uint32_t child_cnt = lv_obj_get_child_cnt(btn);
if (child_cnt > 0) {
lv_obj_t* label = lv_obj_get_child(btn, child_cnt - 1);
if (label) {
#if ENABLE_MEMO_LIST_SCROLL_ANIM
// Allow scrolling when flag enabled – but force DOT during audio if that flag is off
#if ENABLE_MEMO_LIST_SCROLL_DURING_AUDIO
lv_label_set_long_mode(label, LV_LABEL_LONG_SCROLL_CIRCULAR);
#else
// During audio we still force DOT; this func runs at list refresh (idle time)
// To respect flag for idle, we set SCROLL here; update_ui will force DOT during audio if needed
lv_label_set_long_mode(label, LV_LABEL_LONG_SCROLL_CIRCULAR);
#endif
#else
lv_label_set_long_mode(label, LV_LABEL_LONG_DOT);
#endif
}
}
if (i == ctx->selected_index) {
lv_obj_add_state(btn, LV_STATE_CHECKED);
}
}
}
// Helper to force DOT on memo list during audio playback/recording if needed
static void set_memo_list_scroll_enabled(AppCtx* ctx, bool enable) {
if (!ctx->lst_memos) return;
#if ENABLE_MEMO_LIST_SCROLL_DURING_AUDIO
// If flag allows scroll during audio, do nothing
if (enable) return;
#endif
// Force all memo labels to DOT (no animation) when audio active
uint32_t list_child_cnt = lv_obj_get_child_cnt(ctx->lst_memos);
for (uint32_t i = 0; i < list_child_cnt; i++) {
lv_obj_t* btn = lv_obj_get_child(ctx->lst_memos, i);
if (!btn) continue;
uint32_t btn_child_cnt = lv_obj_get_child_cnt(btn);
if (btn_child_cnt == 0) continue;
lv_obj_t* label = lv_obj_get_child(btn, btn_child_cnt - 1);
if (label) {
lv_label_set_long_mode(label, enable ?
#if ENABLE_MEMO_LIST_SCROLL_ANIM
LV_LABEL_LONG_SCROLL_CIRCULAR
#else
LV_LABEL_LONG_DOT
#endif
: LV_LABEL_LONG_DOT);
}
}
}
// Helper to control label scrolling based on audio state
static void set_status_scroll_enabled(AppCtx* ctx, bool enable) {
if (!ctx->lbl_status) return;
if (enable) {
#if ENABLE_SCROLL_ANIM_WHEN_IDLE
// Allow scrolling when idle if flag says so, otherwise DOT
// We intentionally keep status as DOT even when idle to avoid
// unnecessary redraws, but this hook lets future tweaks enable it.
lv_label_set_long_mode(ctx->lbl_status, LV_LABEL_LONG_DOT);
#else
lv_label_set_long_mode(ctx->lbl_status, LV_LABEL_LONG_DOT);
#endif
} else {
#if ENABLE_SCROLL_ANIM_DURING_AUDIO
lv_label_set_long_mode(ctx->lbl_status, LV_LABEL_LONG_SCROLL_CIRCULAR);
#else
lv_label_set_long_mode(ctx->lbl_status, LV_LABEL_LONG_DOT);
#endif
}
}
static void update_ui(AppCtx* ctx) {
if (ctx->lbl_status == NULL) return;
switch (ctx->state) {
case STATE_RECORDING:
set_status_scroll_enabled(ctx, false);
set_memo_list_scroll_enabled(ctx, false);
lv_obj_add_state(ctx->btn_record, LV_STATE_DISABLED);
lv_obj_add_state(ctx->btn_play_pause, LV_STATE_DISABLED);
lv_obj_clear_state(ctx->btn_stop, LV_STATE_DISABLED);
@@ -567,6 +669,8 @@ static void update_ui(AppCtx* ctx) {
break;
case STATE_PLAYING:
set_status_scroll_enabled(ctx, false);
set_memo_list_scroll_enabled(ctx, false);
lv_obj_add_state(ctx->btn_record, LV_STATE_DISABLED);
lv_obj_clear_state(ctx->btn_play_pause, LV_STATE_DISABLED);
lv_label_set_text(lv_obj_get_child(ctx->btn_play_pause, 0), LV_SYMBOL_PAUSE);
@@ -575,6 +679,8 @@ static void update_ui(AppCtx* ctx) {
break;
case STATE_PAUSED:
set_status_scroll_enabled(ctx, false);
set_memo_list_scroll_enabled(ctx, false);
lv_obj_add_state(ctx->btn_record, LV_STATE_DISABLED);
lv_obj_clear_state(ctx->btn_play_pause, LV_STATE_DISABLED);
lv_label_set_text(lv_obj_get_child(ctx->btn_play_pause, 0), LV_SYMBOL_PLAY);
@@ -584,6 +690,8 @@ static void update_ui(AppCtx* ctx) {
case STATE_IDLE:
default:
set_status_scroll_enabled(ctx, true);
set_memo_list_scroll_enabled(ctx, true);
lv_obj_clear_state(ctx->btn_record, LV_STATE_DISABLED);
if (ctx->selected_index >= 0 && ctx->selected_index < ctx->memo_count) {
@@ -642,6 +750,7 @@ static void onShowApp(AppHandle app, void* data, lv_obj_t* parent) {
lv_obj_set_width(g_ctx.lbl_status, lv_pct(95));
lv_obj_set_style_text_align(g_ctx.lbl_status, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_style_text_color(g_ctx.lbl_status, lv_color_hex(0xCDD6F4), 0);
lv_label_set_long_mode(g_ctx.lbl_status, LV_LABEL_LONG_DOT);
lv_label_set_text(g_ctx.lbl_status, "Scanning memos...");
g_ctx.bar_progress = lv_bar_create(card);