Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 30de6beee7 | |||
| 4ff787ff1d |
@@ -0,0 +1,12 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.20)
|
||||||
|
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||||
|
if (DEFINED ENV{TACTILITY_SDK_PATH})
|
||||||
|
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
|
||||||
|
else()
|
||||||
|
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
|
||||||
|
message(WARNING "TACTILITY_SDK_PATH not set, defaulting to ${TACTILITY_SDK_PATH}")
|
||||||
|
endif()
|
||||||
|
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
|
||||||
|
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
|
||||||
|
project(DiscoveryMountain)
|
||||||
|
tactility_project(DiscoveryMountain)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
file(GLOB_RECURSE SOURCE_FILES Source/*.c)
|
||||||
|
idf_component_register(
|
||||||
|
SRCS ${SOURCE_FILES}
|
||||||
|
REQUIRES TactilitySDK esp_http_client json
|
||||||
|
)
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
#include <tt_app.h>
|
||||||
|
#include <tt_lvgl.h>
|
||||||
|
#include <tt_lvgl_toolbar.h>
|
||||||
|
#include <tt_bundle.h>
|
||||||
|
#include <tactility/device.h>
|
||||||
|
#include <tactility/drivers/i2s_controller.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <dirent.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include "freertos/FreeRTOS.h"
|
||||||
|
#include "freertos/task.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include "esp_http_client.h"
|
||||||
|
#include "cJSON.h"
|
||||||
|
#define MINIMP3_IMPLEMENTATION
|
||||||
|
#define MINIMP3_NO_SIMD
|
||||||
|
#include "minimp3.h"
|
||||||
|
|
||||||
|
#define TAG "DM"
|
||||||
|
#define PB "http://192.168.68.110:8095"
|
||||||
|
#define DM_DIR "/sdcard/dm"
|
||||||
|
#define STATE_PATH "/sdcard/apps/one.tactility.discoverymountain/userdata/state.json"
|
||||||
|
#define MAX_SEASONS 40
|
||||||
|
#define MAX_EPS 400
|
||||||
|
#define MP3_BUF 16384
|
||||||
|
|
||||||
|
typedef struct { int num; } SeasonInfo;
|
||||||
|
typedef struct { char title[128]; char slug[96]; int season; int ep_num; char audio_url[256]; bool seen; } EpInfo;
|
||||||
|
|
||||||
|
static struct {
|
||||||
|
SeasonInfo seasons[MAX_SEASONS]; int season_cnt;
|
||||||
|
EpInfo eps[MAX_EPS]; int ep_cnt;
|
||||||
|
int cur_season;
|
||||||
|
int cur_ep_idx;
|
||||||
|
struct Device* i2s_dev;
|
||||||
|
bool is_playing; bool is_paused; bool need_stop;
|
||||||
|
TaskHandle_t play_handle;
|
||||||
|
int pos_sec; int total_sec;
|
||||||
|
int volume;
|
||||||
|
lv_obj_t *lbl_title, *lbl_meta, *bar, *lbl_time, *lbl_status, *btn_play;
|
||||||
|
lv_obj_t *overlay, *roller;
|
||||||
|
char cur_title[128];
|
||||||
|
} G;
|
||||||
|
|
||||||
|
static const char* sd_path(const char* slug){ static char p[256]; snprintf(p,sizeof(p),"%s/%s.mp3",DM_DIR,slug); return p; }
|
||||||
|
static void ensure_dir(){ mkdir("/sdcard",0777); mkdir("/sdcard/apps",0777); mkdir("/sdcard/apps/one.tactility.discoverymountain",0777); mkdir("/sdcard/apps/one.tactility.discoverymountain/userdata",0777); mkdir(DM_DIR,0777); }
|
||||||
|
static bool has_slug(const char* slug){ struct stat st; return stat(sd_path(slug),&st)==0; }
|
||||||
|
|
||||||
|
static void save_state(){
|
||||||
|
cJSON* r=cJSON_CreateObject();
|
||||||
|
if(G.ep_cnt>0 && G.cur_ep_idx>=0) cJSON_AddStringToObject(r,"last_slug",G.eps[G.cur_ep_idx].slug);
|
||||||
|
cJSON_AddNumberToObject(r,"last_season",G.cur_season);
|
||||||
|
cJSON_AddNumberToObject(r,"pos_sec",G.pos_sec);
|
||||||
|
char* s=cJSON_PrintUnformatted(r);
|
||||||
|
if(s){ FILE* f=fopen(STATE_PATH,"w"); if(f){ fwrite(s,1,strlen(s),f); fclose(f);} free(s);} cJSON_Delete(r);
|
||||||
|
}
|
||||||
|
static void load_state(){
|
||||||
|
G.cur_season=1; G.cur_ep_idx=-1; G.pos_sec=0;
|
||||||
|
FILE* f=fopen(STATE_PATH,"r"); if(!f) return;
|
||||||
|
fseek(f,0,SEEK_END); long sz=ftell(f); fseek(f,0,SEEK_SET); if(sz<=0||sz>4096){ fclose(f); return;}
|
||||||
|
char* buf=malloc(sz+1); fread(buf,1,sz,f); buf[sz]=0; fclose(f);
|
||||||
|
cJSON* r=cJSON_Parse(buf); free(buf); if(!r) return;
|
||||||
|
cJSON* ls=cJSON_GetObjectItem(r,"last_slug");
|
||||||
|
cJSON* lsn=cJSON_GetObjectItem(r,"last_season");
|
||||||
|
cJSON* ps=cJSON_GetObjectItem(r,"pos_sec");
|
||||||
|
if(ps&&cJSON_IsNumber(ps)) G.pos_sec=ps->valueint;
|
||||||
|
if(lsn&&cJSON_IsNumber(lsn)) G.cur_season=lsn->valueint;
|
||||||
|
if(ls&&cJSON_IsString(ls)){
|
||||||
|
for(int i=0;i<G.ep_cnt;i++) if(strcmp(G.eps[i].slug,ls->valuestring)==0){ G.cur_ep_idx=i; G.cur_season=G.eps[i].season; break; }
|
||||||
|
}
|
||||||
|
cJSON_Delete(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int cmp_season(const void*a,const void*b){ return ((SeasonInfo*)a)->num - ((SeasonInfo*)b)->num; }
|
||||||
|
static int cmp_ep(const void*a,const void*b){ EpInfo* ea=(EpInfo*)a; EpInfo* eb=(EpInfo*)b; if(ea->season!=eb->season) return ea->season-eb->season; return ea->ep_num-eb->ep_num; }
|
||||||
|
|
||||||
|
static int http_get(const char* url, char** out){
|
||||||
|
esp_http_client_config_t cfg={ .url=url, .timeout_ms=8000 };
|
||||||
|
esp_http_client_handle_t c=esp_http_client_init(&cfg); if(!c) return -1;
|
||||||
|
esp_http_client_set_method(c,HTTP_METHOD_GET);
|
||||||
|
if(esp_http_client_perform(c)!=ESP_OK){ esp_http_client_cleanup(c); return -1; }
|
||||||
|
if(esp_http_client_get_status_code(c)!=200){ esp_http_client_cleanup(c); return -1; }
|
||||||
|
int len=esp_http_client_get_content_length(c); if(len<=0) len=65536;
|
||||||
|
char* buf=malloc(len+1); int rlen=esp_http_client_read_response(c,buf,len); buf[rlen]=0;
|
||||||
|
esp_http_client_cleanup(c); *out=buf; return rlen;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void fetch_data(){
|
||||||
|
char* js=NULL; char url[256];
|
||||||
|
snprintf(url,sizeof(url),"%s/api/collections/dm_seasons/records?perPage=100&sort=season_num",PB);
|
||||||
|
if(http_get(url,&js)>0){
|
||||||
|
cJSON* root=cJSON_Parse(js); free(js); js=NULL;
|
||||||
|
if(root){
|
||||||
|
cJSON* items=cJSON_GetObjectItem(root,"items");
|
||||||
|
if(cJSON_IsArray(items)){
|
||||||
|
G.season_cnt=0;
|
||||||
|
int n=cJSON_GetArraySize(items);
|
||||||
|
for(int i=0;i<n&&G.season_cnt<MAX_SEASONS;i++){
|
||||||
|
cJSON* it=cJSON_GetArrayItem(items,i);
|
||||||
|
cJSON* sn=cJSON_GetObjectItem(it,"season_num");
|
||||||
|
if(sn&&cJSON_IsNumber(sn)){ G.seasons[G.season_cnt].num=sn->valueint; G.season_cnt++; }
|
||||||
|
}
|
||||||
|
qsort(G.seasons,G.season_cnt,sizeof(SeasonInfo),cmp_season);
|
||||||
|
}
|
||||||
|
cJSON_Delete(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(G.season_cnt==0){ for(int i=1;i<=37;i++) G.seasons[i-1].num=i; G.season_cnt=37; }
|
||||||
|
|
||||||
|
snprintf(url,sizeof(url),"%s/api/collections/dm_episodes/records?perPage=400&sort=season_num,episode_num",PB);
|
||||||
|
if(http_get(url,&js)>0){
|
||||||
|
cJSON* root=cJSON_Parse(js); free(js); js=NULL;
|
||||||
|
if(root){
|
||||||
|
cJSON* items=cJSON_GetObjectItem(root,"items");
|
||||||
|
if(cJSON_IsArray(items)){
|
||||||
|
G.ep_cnt=0;
|
||||||
|
int n=cJSON_GetArraySize(items);
|
||||||
|
for(int i=0;i<n&&G.ep_cnt<MAX_EPS;i++){
|
||||||
|
cJSON* it=cJSON_GetArrayItem(items,i);
|
||||||
|
cJSON* t=cJSON_GetObjectItem(it,"title");
|
||||||
|
cJSON* slug=cJSON_GetObjectItem(it,"slug");
|
||||||
|
cJSON* sn=cJSON_GetObjectItem(it,"season_num");
|
||||||
|
cJSON* en=cJSON_GetObjectItem(it,"episode_num");
|
||||||
|
cJSON* au=cJSON_GetObjectItem(it,"audio_url");
|
||||||
|
if(!sn||!en) continue;
|
||||||
|
EpInfo* e=&G.eps[G.ep_cnt]; memset(e,0,sizeof(*e));
|
||||||
|
if(t&&cJSON_IsString(t)) strncpy(e->title,t->valuestring,sizeof(e->title)-1);
|
||||||
|
if(slug&&cJSON_IsString(slug)) strncpy(e->slug,slug->valuestring,sizeof(e->slug)-1);
|
||||||
|
else if(t) strncpy(e->slug,t->valuestring,sizeof(e->slug)-1);
|
||||||
|
e->season=sn->valueint; e->ep_num=en->valueint;
|
||||||
|
if(au&&cJSON_IsString(au)) strncpy(e->audio_url,au->valuestring,sizeof(e->audio_url)-1);
|
||||||
|
e->seen=has_slug(e->slug);
|
||||||
|
G.ep_cnt++;
|
||||||
|
}
|
||||||
|
qsort(G.eps,G.ep_cnt,sizeof(EpInfo),cmp_ep);
|
||||||
|
}
|
||||||
|
cJSON_Delete(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(G.ep_cnt==0){
|
||||||
|
for(int i=0;i<6;i++){ EpInfo* e=&G.eps[i]; snprintf(e->slug,sizeof(e->slug),"S01E%02d",i+1); snprintf(e->title,sizeof(e->title),"Episode %d",i+1); e->season=1; e->ep_num=i+1; }
|
||||||
|
G.ep_cnt=6;
|
||||||
|
}
|
||||||
|
for(int i=0;i<G.ep_cnt;i++) G.eps[i].seen=has_slug(G.eps[i].slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void lru_check(){
|
||||||
|
typedef struct{ char path[300]; time_t mt; } FE;
|
||||||
|
FE files[200];
|
||||||
|
int fc=0;
|
||||||
|
DIR* d=opendir(DM_DIR);
|
||||||
|
if(!d) return;
|
||||||
|
struct dirent* de;
|
||||||
|
while((de=readdir(d))!=NULL && fc<200){
|
||||||
|
if(de->d_name[0]=='.') continue;
|
||||||
|
size_t l=strlen(de->d_name);
|
||||||
|
if(l<5) continue;
|
||||||
|
if(strcmp(de->d_name+l-4,".mp3")!=0) continue;
|
||||||
|
char full[300];
|
||||||
|
snprintf(full,sizeof(full),"%s/%.200s",DM_DIR,de->d_name);
|
||||||
|
struct stat st;
|
||||||
|
if(stat(full,&st)!=0) continue;
|
||||||
|
if(G.cur_ep_idx>=0){
|
||||||
|
const char* curp=sd_path(G.eps[G.cur_ep_idx].slug);
|
||||||
|
if(strcmp(full,curp)==0) continue;
|
||||||
|
}
|
||||||
|
strncpy(files[fc].path,full,sizeof(files[fc].path)-1);
|
||||||
|
files[fc].mt=st.st_mtime;
|
||||||
|
fc++;
|
||||||
|
}
|
||||||
|
closedir(d);
|
||||||
|
if(fc<12) return;
|
||||||
|
for(int i=0;i<fc;i++){
|
||||||
|
for(int j=i+1;j<fc;j++){
|
||||||
|
if(files[j].mt<files[i].mt){ FE t=files[i]; files[i]=files[j]; files[j]=t; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int todel=fc-8;
|
||||||
|
for(int i=0;i<todel;i++) unlink(files[i].path);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool dl_ep(EpInfo* ep){
|
||||||
|
if(!ep) return false;
|
||||||
|
if(has_slug(ep->slug)) return true;
|
||||||
|
ensure_dir(); lru_check();
|
||||||
|
if(strlen(ep->audio_url)==0) return false;
|
||||||
|
char tmp[300]; snprintf(tmp,sizeof(tmp),"%s.tmp",sd_path(ep->slug));
|
||||||
|
FILE* f=fopen(tmp,"wb"); if(!f) return false;
|
||||||
|
esp_http_client_config_t cfg={ .url=ep->audio_url, .timeout_ms=30000 };
|
||||||
|
esp_http_client_handle_t c=esp_http_client_init(&cfg); if(!c){ fclose(f); return false; }
|
||||||
|
if(esp_http_client_perform(c)!=ESP_OK){ esp_http_client_cleanup(c); fclose(f); unlink(tmp); return false; }
|
||||||
|
if(esp_http_client_get_status_code(c)!=200){ esp_http_client_cleanup(c); fclose(f); unlink(tmp); return false; }
|
||||||
|
char buf[2048]; int total=0;
|
||||||
|
while(1){
|
||||||
|
int r=esp_http_client_read(c,buf,sizeof(buf));
|
||||||
|
if(r<=0) break;
|
||||||
|
fwrite(buf,1,r,f);
|
||||||
|
total+=r;
|
||||||
|
if(G.lbl_status){ char m[32]; snprintf(m,sizeof(m),"DL %dKB",total/1024); lv_label_set_text(G.lbl_status,m);}
|
||||||
|
}
|
||||||
|
esp_http_client_cleanup(c); fclose(f);
|
||||||
|
if(total<1024){ unlink(tmp); return false; }
|
||||||
|
rename(tmp,sd_path(ep->slug));
|
||||||
|
ep->seen=true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void stop_play(){
|
||||||
|
if(G.play_handle){ G.need_stop=true; vTaskDelay(pdMS_TO_TICKS(100)); if(G.play_handle){ vTaskDelete(G.play_handle); G.play_handle=NULL; } }
|
||||||
|
if(G.i2s_dev){ device_lock(G.i2s_dev); i2s_controller_reset(G.i2s_dev); device_unlock(G.i2s_dev); G.i2s_dev=NULL; }
|
||||||
|
G.is_playing=false; G.is_paused=false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void play_task(void* arg){
|
||||||
|
(void)arg;
|
||||||
|
int idx=G.cur_ep_idx;
|
||||||
|
if(idx<0||idx>=G.ep_cnt){ G.play_handle=NULL; vTaskDelete(NULL); return; }
|
||||||
|
EpInfo* ep=&G.eps[idx];
|
||||||
|
FILE* file=fopen(sd_path(ep->slug),"rb");
|
||||||
|
if(!file){ G.is_playing=false; G.play_handle=NULL; vTaskDelete(NULL); return; }
|
||||||
|
fseek(file,0,SEEK_END); long fsize=ftell(file); fseek(file,0,SEEK_SET);
|
||||||
|
G.i2s_dev=device_find_by_name("i2s0");
|
||||||
|
if(!G.i2s_dev){ fclose(file); G.is_playing=false; G.play_handle=NULL; vTaskDelete(NULL); return; }
|
||||||
|
if(fsize>0) G.total_sec=(int)(fsize/16000);
|
||||||
|
|
||||||
|
mp3dec_t dec; mp3dec_init(&dec);
|
||||||
|
uint8_t* inbuf=malloc(MP3_BUF);
|
||||||
|
int16_t* pcm=malloc(1152*2*2);
|
||||||
|
size_t buffered=0;
|
||||||
|
mp3dec_frame_info_t info; memset(&info,0,sizeof(info));
|
||||||
|
bool configured=false;
|
||||||
|
size_t r=fread(inbuf,1,MP3_BUF,file); buffered=r;
|
||||||
|
|
||||||
|
while(!G.need_stop){
|
||||||
|
if(G.is_paused){ vTaskDelay(pdMS_TO_TICKS(100)); continue; }
|
||||||
|
int samples=mp3dec_decode_frame(&dec,inbuf,(int)buffered,pcm,&info);
|
||||||
|
if(samples>0){
|
||||||
|
if(!configured){
|
||||||
|
struct I2sConfig cfg={ .communication_format=I2S_FORMAT_STAND_I2S, .sample_rate=(uint32_t)info.hz, .bits_per_sample=16, .channel_left=0, .channel_right=(info.channels==2)?1:I2S_CHANNEL_NONE };
|
||||||
|
device_lock(G.i2s_dev); i2s_controller_set_config(G.i2s_dev,&cfg); device_unlock(G.i2s_dev);
|
||||||
|
if(G.pos_sec>5){ long seek=(long)G.pos_sec*16000; if(seek<fsize){ fseek(file,seek,SEEK_SET); buffered=0; } }
|
||||||
|
configured=true;
|
||||||
|
}
|
||||||
|
float vol=G.volume/100.0f; if(vol<0) vol=0; if(vol>1) vol=1;
|
||||||
|
for(int i=0;i<samples*2;i++){ int32_t s=pcm[i]; s=(int32_t)(s*vol); if(s>32767) s=32767; if(s<-32768) s=-32768; pcm[i]=(int16_t)s; }
|
||||||
|
size_t to_write=samples*2*2; size_t off=0;
|
||||||
|
while(off<to_write && !G.need_stop){
|
||||||
|
if(G.is_paused){ vTaskDelay(pdMS_TO_TICKS(100)); continue; }
|
||||||
|
size_t w=0;
|
||||||
|
device_lock(G.i2s_dev);
|
||||||
|
esp_err_t e=i2s_controller_write(G.i2s_dev,(uint8_t*)pcm+off,to_write-off,&w,pdMS_TO_TICKS(500));
|
||||||
|
device_unlock(G.i2s_dev);
|
||||||
|
if(e==ESP_OK) off+=w; else vTaskDelay(pdMS_TO_TICKS(10));
|
||||||
|
}
|
||||||
|
if(info.hz>0) G.pos_sec += samples / info.hz;
|
||||||
|
}
|
||||||
|
if(info.frame_bytes>0 && info.frame_bytes <= (int)buffered){
|
||||||
|
memmove(inbuf,inbuf+info.frame_bytes,buffered-info.frame_bytes);
|
||||||
|
buffered-=info.frame_bytes;
|
||||||
|
} else buffered=0;
|
||||||
|
if(buffered < MP3_BUF/2){
|
||||||
|
size_t nr=fread(inbuf+buffered,1,MP3_BUF-buffered,file);
|
||||||
|
if(nr>0) buffered+=nr; else { if(samples==0) break; }
|
||||||
|
}
|
||||||
|
if(buffered==0) break;
|
||||||
|
}
|
||||||
|
fclose(file); free(inbuf); free(pcm);
|
||||||
|
if(G.i2s_dev){ device_lock(G.i2s_dev); i2s_controller_reset(G.i2s_dev); device_unlock(G.i2s_dev); G.i2s_dev=NULL; }
|
||||||
|
G.is_playing=false; G.play_handle=NULL; vTaskDelete(NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void start_idx(int idx){
|
||||||
|
if(idx<0||idx>=G.ep_cnt) return;
|
||||||
|
if(G.cur_ep_idx!=idx) G.pos_sec=0;
|
||||||
|
EpInfo* ep=&G.eps[idx];
|
||||||
|
if(!has_slug(ep->slug)){
|
||||||
|
if(G.lbl_status) lv_label_set_text(G.lbl_status,"Downloading...");
|
||||||
|
if(!dl_ep(ep)){ if(G.lbl_status) lv_label_set_text(G.lbl_status,"DL failed"); return; }
|
||||||
|
}
|
||||||
|
stop_play();
|
||||||
|
G.cur_ep_idx=idx; G.cur_season=ep->season; strncpy(G.cur_title,ep->title,sizeof(G.cur_title)-1);
|
||||||
|
G.need_stop=false; G.is_paused=false; G.is_playing=true;
|
||||||
|
save_state();
|
||||||
|
xTaskCreate(play_task,"dm_play",6144,NULL,5,&G.play_handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void select_ep(int idx){
|
||||||
|
if(idx<0||idx>=G.ep_cnt) return;
|
||||||
|
G.cur_ep_idx=idx; EpInfo* ep=&G.eps[idx]; G.cur_season=ep->season;
|
||||||
|
if(G.lbl_title) lv_label_set_text(G.lbl_title,ep->title);
|
||||||
|
if(G.lbl_meta){ char b[48]; snprintf(b,sizeof(b),"S%02d E%02d %s",ep->season,ep->ep_num,ep->seen?"*":""); lv_label_set_text(G.lbl_meta,b); }
|
||||||
|
if(G.lbl_status) lv_label_set_text(G.lbl_status,ep->seen?"On SD":"Tap Play to DL");
|
||||||
|
if(G.bar) lv_bar_set_value(G.bar,0,LV_ANIM_OFF);
|
||||||
|
if(G.lbl_time) lv_label_set_text(G.lbl_time,"0:00 / --:--");
|
||||||
|
G.pos_sec=0; save_state();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void go_next(){ if(G.ep_cnt==0) return; int n=G.cur_ep_idx+1; if(n>=G.ep_cnt) n=0; select_ep(n); start_idx(n); }
|
||||||
|
static void go_prev(){ if(G.ep_cnt==0) return; if(G.pos_sec>5){ G.pos_sec=0; return; } int p=G.cur_ep_idx-1; if(p<0) p=G.ep_cnt-1; select_ep(p); start_idx(p); }
|
||||||
|
static void fmt_time(char* o,int s){ snprintf(o,16,"%d:%02d",s/60,s%60); }
|
||||||
|
|
||||||
|
static void ui_timer(lv_timer_t* t){
|
||||||
|
(void)t;
|
||||||
|
if(G.bar){
|
||||||
|
if(G.total_sec>0){ int pct=G.pos_sec*100/G.total_sec; if(pct>100) pct=100; lv_bar_set_value(G.bar,pct,LV_ANIM_OFF); }
|
||||||
|
else if(G.is_playing){ lv_bar_set_value(G.bar,(G.pos_sec%10)*10,LV_ANIM_OFF); }
|
||||||
|
}
|
||||||
|
if(G.lbl_time){
|
||||||
|
char a[16],b[16]; fmt_time(a,G.pos_sec); if(G.total_sec>0) fmt_time(b,G.total_sec); else snprintf(b,sizeof(b),"--:--");
|
||||||
|
char buf[40]; snprintf(buf,sizeof(buf),"%s / %s",a,b); lv_label_set_text(G.lbl_time,buf);
|
||||||
|
}
|
||||||
|
if(G.is_playing && G.lbl_status) lv_label_set_text(G.lbl_status,"Playing");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void btn_play_cb(lv_event_t* e){
|
||||||
|
(void)e;
|
||||||
|
if(G.is_playing && !G.is_paused){
|
||||||
|
G.is_paused=true;
|
||||||
|
if(G.lbl_status) lv_label_set_text(G.lbl_status,"Paused");
|
||||||
|
if(G.btn_play){ lv_obj_t* ch=lv_obj_get_child(G.btn_play,0); if(ch) lv_label_set_text(ch,LV_SYMBOL_PLAY);}
|
||||||
|
} else if(G.is_paused){
|
||||||
|
G.is_paused=false;
|
||||||
|
if(G.btn_play){ lv_obj_t* ch=lv_obj_get_child(G.btn_play,0); if(ch) lv_label_set_text(ch,LV_SYMBOL_PAUSE);}
|
||||||
|
} else {
|
||||||
|
start_idx(G.cur_ep_idx);
|
||||||
|
if(G.btn_play){ lv_obj_t* ch=lv_obj_get_child(G.btn_play,0); if(ch) lv_label_set_text(ch,LV_SYMBOL_PAUSE);}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
static void btn_prev_cb(lv_event_t* e){ (void)e; go_prev(); }
|
||||||
|
static void btn_next_cb(lv_event_t* e){ (void)e; go_next(); }
|
||||||
|
static void close_overlay(){ if(G.overlay){ lv_obj_del(G.overlay); G.overlay=NULL; G.roller=NULL; } }
|
||||||
|
static void roller_cb(lv_event_t* e){
|
||||||
|
if(lv_event_get_code(e)!=LV_EVENT_VALUE_CHANGED) return;
|
||||||
|
int sel=lv_roller_get_selected(G.roller); if(sel<0||sel>=G.season_cnt) return;
|
||||||
|
int sn=G.seasons[sel].num; close_overlay();
|
||||||
|
int first=-1, first_unseen=-1;
|
||||||
|
for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==sn){ if(first==-1) first=i; if(!G.eps[i].seen && first_unseen==-1) first_unseen=i; }
|
||||||
|
int tgt=first_unseen!=-1?first_unseen:first;
|
||||||
|
if(tgt!=-1){ G.cur_season=sn; select_ep(tgt); }
|
||||||
|
}
|
||||||
|
static void btn_seasons_cb(lv_event_t* e){
|
||||||
|
(void)e; if(G.overlay){ close_overlay(); return; }
|
||||||
|
G.overlay=lv_obj_create(lv_scr_act()); lv_obj_set_size(G.overlay,240,280); lv_obj_center(G.overlay);
|
||||||
|
lv_obj_set_style_bg_color(G.overlay,lv_color_hex(0x222222),0); lv_obj_set_style_radius(G.overlay,12,0);
|
||||||
|
lv_obj_t* title=lv_label_create(G.overlay); lv_label_set_text(title,"Select Season"); lv_obj_align(title,LV_ALIGN_TOP_MID,0,8);
|
||||||
|
static char opts[1024]; opts[0]=0;
|
||||||
|
for(int i=0;i<G.season_cnt;i++){ if(i==0) snprintf(opts,sizeof(opts),"S%d",G.seasons[i].num); else { char c[16]; snprintf(c,sizeof(c),"\nS%d",G.seasons[i].num); strncat(opts,c,sizeof(opts)-strlen(opts)-1);} }
|
||||||
|
G.roller=lv_roller_create(G.overlay); lv_roller_set_options(G.roller,opts,LV_ROLLER_MODE_NORMAL);
|
||||||
|
lv_obj_set_size(G.roller,120,180); lv_obj_align(G.roller,LV_ALIGN_CENTER,0,10);
|
||||||
|
int sel=0; for(int i=0;i<G.season_cnt;i++) if(G.seasons[i].num==G.cur_season) sel=i;
|
||||||
|
lv_roller_set_selected(G.roller,sel,LV_ANIM_OFF); lv_obj_add_event_cb(G.roller,roller_cb,LV_EVENT_VALUE_CHANGED,NULL);
|
||||||
|
lv_obj_t* bc=lv_btn_create(G.overlay); lv_obj_set_size(bc,80,32); lv_obj_align(bc,LV_ALIGN_BOTTOM_MID,0,-8);
|
||||||
|
lv_obj_t* lc=lv_label_create(bc); lv_label_set_text(lc,"Close"); lv_obj_center(lc);
|
||||||
|
lv_obj_add_event_cb(bc,(lv_event_cb_t)close_overlay,LV_EVENT_CLICKED,NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void build_ui(){
|
||||||
|
lv_obj_t* root=lv_obj_create(lv_scr_act()); lv_obj_set_size(root,LV_PCT(100),LV_PCT(100));
|
||||||
|
lv_obj_set_style_bg_color(root,lv_color_hex(0x111111),0); lv_obj_set_style_pad_all(root,8,0); lv_obj_set_style_border_width(root,0,0);
|
||||||
|
G.lbl_title=lv_label_create(root); lv_label_set_text(G.lbl_title,"Discovery Mountain"); lv_obj_set_style_text_font(G.lbl_title,&lv_font_montserrat_14,0); lv_obj_set_width(G.lbl_title,LV_PCT(100)); lv_label_set_long_mode(G.lbl_title,LV_LABEL_LONG_WRAP); lv_obj_align(G.lbl_title,LV_ALIGN_TOP_LEFT,0,0);
|
||||||
|
G.lbl_meta=lv_label_create(root); lv_label_set_text(G.lbl_meta,"S01 E01"); lv_obj_set_style_text_color(G.lbl_meta,lv_color_hex(0xAAAAAA),0); lv_obj_align(G.lbl_meta,LV_ALIGN_TOP_LEFT,0,22);
|
||||||
|
G.bar=lv_bar_create(root); lv_obj_set_size(G.bar,LV_PCT(100),12); lv_obj_align(G.bar,LV_ALIGN_TOP_LEFT,0,44); lv_bar_set_value(G.bar,0,LV_ANIM_OFF);
|
||||||
|
G.lbl_time=lv_label_create(root); lv_label_set_text(G.lbl_time,"0:00 / --:--"); lv_obj_align(G.lbl_time,LV_ALIGN_TOP_LEFT,0,60);
|
||||||
|
G.lbl_status=lv_label_create(root); lv_label_set_text(G.lbl_status,"Ready"); lv_obj_set_style_text_color(G.lbl_status,lv_color_hex(0x888888),0); lv_obj_align(G.lbl_status,LV_ALIGN_TOP_LEFT,0,76);
|
||||||
|
lv_obj_t* row=lv_obj_create(root); lv_obj_set_size(row,LV_PCT(100),56); lv_obj_align(row,LV_ALIGN_TOP_LEFT,0,98); lv_obj_set_flex_flow(row,LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(row,LV_FLEX_ALIGN_SPACE_EVENLY,LV_FLEX_ALIGN_CENTER,LV_FLEX_ALIGN_CENTER); lv_obj_set_style_bg_opa(row,LV_OPA_TRANSP,0); lv_obj_set_style_border_width(row,0,0);
|
||||||
|
lv_obj_t* b1=lv_btn_create(row); lv_obj_set_size(b1,56,48); lv_obj_t* l1=lv_label_create(b1); lv_label_set_text(l1,LV_SYMBOL_PREV); lv_obj_center(l1); lv_obj_add_event_cb(b1,btn_prev_cb,LV_EVENT_CLICKED,NULL);
|
||||||
|
G.btn_play=lv_btn_create(row); lv_obj_set_size(G.btn_play,72,48); lv_obj_set_style_bg_color(G.btn_play,lv_color_hex(0x2E7D32),0); lv_obj_t* l2=lv_label_create(G.btn_play); lv_label_set_text(l2,LV_SYMBOL_PLAY); lv_obj_center(l2); lv_obj_add_event_cb(G.btn_play,btn_play_cb,LV_EVENT_CLICKED,NULL);
|
||||||
|
lv_obj_t* b3=lv_btn_create(row); lv_obj_set_size(b3,56,48); lv_obj_t* l3=lv_label_create(b3); lv_label_set_text(l3,LV_SYMBOL_NEXT); lv_obj_center(l3); lv_obj_add_event_cb(b3,btn_next_cb,LV_EVENT_CLICKED,NULL);
|
||||||
|
lv_obj_t* row2=lv_obj_create(root); lv_obj_set_size(row2,LV_PCT(100),44); lv_obj_align(row2,LV_ALIGN_BOTTOM_LEFT,0,0); lv_obj_set_flex_flow(row2,LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(row2,LV_FLEX_ALIGN_SPACE_EVENLY,LV_FLEX_ALIGN_CENTER,LV_FLEX_ALIGN_CENTER); lv_obj_set_style_bg_opa(row2,LV_OPA_TRANSP,0); lv_obj_set_style_border_width(row2,0,0);
|
||||||
|
lv_obj_t* bs=lv_btn_create(row2); lv_obj_set_size(bs,LV_PCT(100),36); lv_obj_t* ls=lv_label_create(bs); lv_label_set_text(ls,"Seasons"); lv_obj_center(ls); lv_obj_add_event_cb(bs,btn_seasons_cb,LV_EVENT_CLICKED,NULL);
|
||||||
|
lv_timer_create(ui_timer,500,NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void onShow(AppHandle app, void* data, lv_obj_t* parent){
|
||||||
|
(void)app; (void)data; (void)parent;
|
||||||
|
memset(&G,0,0); G.pos_sec=0; G.total_sec=0; G.cur_season=1; G.cur_ep_idx=-1; G.volume=80; ensure_dir();
|
||||||
|
load_state(); fetch_data(); build_ui();
|
||||||
|
if(G.cur_ep_idx<0){ int s1=-1; for(int i=0;i<G.ep_cnt;i++) if(G.eps[i].season==G.cur_season){ s1=i; break; } if(s1==-1) s1=0; G.cur_ep_idx=s1; }
|
||||||
|
if(G.ep_cnt>0 && G.cur_ep_idx>=0) select_ep(G.cur_ep_idx);
|
||||||
|
}
|
||||||
|
static void onHide(AppHandle app, void* data){ (void)app; (void)data; stop_play(); save_state(); }
|
||||||
|
|
||||||
|
int main(int argc, char* argv[]){
|
||||||
|
(void)argc; (void)argv;
|
||||||
|
tt_app_register((AppRegistration){ .onShow=onShow, .onHide=onHide });
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
|||||||
|
[manifest]
|
||||||
|
version=0.1
|
||||||
|
[target]
|
||||||
|
sdk=0.8.0-dev
|
||||||
|
platforms=esp32s3
|
||||||
|
[app]
|
||||||
|
id=one.tactility.discoverymountain
|
||||||
|
name=Discovery Mountain
|
||||||
|
versionName=0.1.0
|
||||||
|
versionCode=1
|
||||||
|
type=user
|
||||||
+142
-150
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* @file main.c
|
* @file main.c
|
||||||
* @brief GameBoy DMG Emulator for Tactility (Peanut-GB prototype, no audio)
|
* @brief GameBoy DMG Emulator – Tactility (Peanut-GB) – I2 canvas version
|
||||||
*
|
* Fixes black screen after reboot: previous RGB565 PSRAM buffer required flush_cache handler which is NULL on ESP32S3 LVGL port.
|
||||||
* MIT licensed Peanut-GB core vendored in Libraries/PeanutGB/peanut_gb.h
|
* I2 (2bpp indexed) = 160*144*2/8 = 5760 bytes – fits internal RAM, no cache coherency, no WiFi/SD starvation.
|
||||||
* See app README for notes.
|
* Uses lv_canvas_set_palette for 4 grays per GB_PALETTE.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#include <tt_app.h>
|
#include <tt_app.h>
|
||||||
@@ -13,11 +13,9 @@
|
|||||||
#include <tt_lvgl_keyboard.h>
|
#include <tt_lvgl_keyboard.h>
|
||||||
|
|
||||||
#include <lvgl.h>
|
#include <lvgl.h>
|
||||||
/* lv_image_cache_drop is not in public LVGL headers but exported by Tactility firmware */
|
|
||||||
void lv_image_cache_drop(const void * src);
|
void lv_image_cache_drop(const void * src);
|
||||||
#include <esp_log.h>
|
|
||||||
|
|
||||||
/* Board firmware 0.8.0-dev/IDF 5.3.2 does not export esp_log for side-loaded ELFs. */
|
#include <esp_log.h>
|
||||||
#undef ESP_LOGI
|
#undef ESP_LOGI
|
||||||
#undef ESP_LOGW
|
#undef ESP_LOGW
|
||||||
#undef ESP_LOGE
|
#undef ESP_LOGE
|
||||||
@@ -40,8 +38,23 @@ void lv_image_cache_drop(const void * src);
|
|||||||
#include "peanut_gb.h"
|
#include "peanut_gb.h"
|
||||||
|
|
||||||
#define TAG "GameBoy"
|
#define TAG "GameBoy"
|
||||||
#define DEFAULT_ROM_PATH "/data/roms/gb/default.gb"
|
#define ROMS_DIR_LEGACY "/data/roms/gb"
|
||||||
#define ROMS_DIR "/data/roms/gb"
|
#define DEFAULT_ROM_PATH_1 "/sdcard/Roms/GB/default.gb"
|
||||||
|
#define DEFAULT_ROM_PATH_2 "/sdcard/roms/gb/default.gb"
|
||||||
|
#define DEFAULT_ROM_PATH_3 "/data/Roms/GB/default.gb"
|
||||||
|
#define DEFAULT_ROM_PATH_4 "/data/roms/gb/default.gb"
|
||||||
|
#define DEFAULT_ROM_PATH DEFAULT_ROM_PATH_1
|
||||||
|
#define ROMS_DIR ROMS_DIR_LEGACY
|
||||||
|
static const char* ROMS_CANDIDATE_DIRS[] = {
|
||||||
|
"/sdcard/Roms/GB",
|
||||||
|
"/sdcard/Roms",
|
||||||
|
"/sdcard/roms/gb",
|
||||||
|
"/sdcard/ROMS/GB",
|
||||||
|
"/data/Roms/GB",
|
||||||
|
"/data/roms/gb",
|
||||||
|
"/data/Roms",
|
||||||
|
ROMS_DIR_LEGACY,
|
||||||
|
};
|
||||||
#define MAX_ROMS 64
|
#define MAX_ROMS 64
|
||||||
#define MAX_PATH 512
|
#define MAX_PATH 512
|
||||||
#define MAX_ROM_SIZE (2 * 1024 * 1024)
|
#define MAX_ROM_SIZE (2 * 1024 * 1024)
|
||||||
@@ -49,14 +62,6 @@ void lv_image_cache_drop(const void * src);
|
|||||||
#define FRAME_H 144
|
#define FRAME_H 144
|
||||||
#define TICK_MS 16
|
#define TICK_MS 16
|
||||||
|
|
||||||
/* RGB565 direct – no bitfield endian ambiguity */
|
|
||||||
static const uint16_t GB_PALETTE[4] = {
|
|
||||||
0xFFFF, // white
|
|
||||||
0x8C51, // light gray ~ 0b10001 100010 10001 but pre-tuned
|
|
||||||
0x4A49, // dark gray
|
|
||||||
0x0000 // black
|
|
||||||
};
|
|
||||||
|
|
||||||
typedef enum { APP_MODE_BROWSER, APP_MODE_EMU } AppMode;
|
typedef enum { APP_MODE_BROWSER, APP_MODE_EMU } AppMode;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
@@ -74,8 +79,8 @@ typedef struct {
|
|||||||
char rom_title[32];
|
char rom_title[32];
|
||||||
uint8_t joypad_state;
|
uint8_t joypad_state;
|
||||||
|
|
||||||
uint16_t* fb_native; /* RGB565 tightly packed 160*144, raw u16 avoids lv_color16_t bitfield */
|
uint8_t* fb_indexed;
|
||||||
lv_draw_buf_t* fb_draw_buf; /* draw_buf that canvas src points to – owned by us so we can invalidate cache */
|
lv_draw_buf_t* fb_draw_buf;
|
||||||
lv_obj_t* canvas;
|
lv_obj_t* canvas;
|
||||||
lv_obj_t* root_wrapper;
|
lv_obj_t* root_wrapper;
|
||||||
lv_obj_t* browser_wrapper;
|
lv_obj_t* browser_wrapper;
|
||||||
@@ -106,7 +111,7 @@ typedef struct {
|
|||||||
bool rom_loaded;
|
bool rom_loaded;
|
||||||
uint32_t fps_frames;
|
uint32_t fps_frames;
|
||||||
int64_t fps_last_us;
|
int64_t fps_last_us;
|
||||||
uint32_t lines_drawn; /* debug: should be 144 per frame */
|
uint32_t lines_drawn;
|
||||||
} AppCtx;
|
} AppCtx;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
@@ -114,15 +119,18 @@ typedef struct {
|
|||||||
uint8_t joypad_bit;
|
uint8_t joypad_bit;
|
||||||
} BtnUserData;
|
} BtnUserData;
|
||||||
|
|
||||||
/* PSRAM helpers */
|
|
||||||
static void* alloc_psram(size_t size) {
|
static void* alloc_psram(size_t size) {
|
||||||
void* p = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
void* p = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||||
if (!p) p = heap_caps_malloc(size, MALLOC_CAP_8BIT);
|
if (!p) p = heap_caps_malloc(size, MALLOC_CAP_8BIT);
|
||||||
if (!p) p = malloc(size);
|
return p;
|
||||||
|
}
|
||||||
|
static void* alloc_internal(size_t size) {
|
||||||
|
void* p = heap_caps_malloc(size, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
|
||||||
|
if (!p) p = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||||
|
if (!p) p = heap_caps_malloc(size, MALLOC_CAP_8BIT);
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Peanut-GB callbacks */
|
|
||||||
static uint8_t gb_rom_read(struct gb_s* gb, const uint_fast32_t addr) {
|
static uint8_t gb_rom_read(struct gb_s* gb, const uint_fast32_t addr) {
|
||||||
AppCtx* ctx = (AppCtx*)gb->direct.priv;
|
AppCtx* ctx = (AppCtx*)gb->direct.priv;
|
||||||
if (!ctx || !ctx->rom_data) return 0xFF;
|
if (!ctx || !ctx->rom_data) return 0xFF;
|
||||||
@@ -140,37 +148,22 @@ static void gb_cart_ram_write(struct gb_s* gb, const uint_fast32_t addr, const u
|
|||||||
if (!ctx || !ctx->cart_ram) return;
|
if (!ctx || !ctx->cart_ram) return;
|
||||||
if (addr < ctx->cart_ram_size) ctx->cart_ram[addr] = val;
|
if (addr < ctx->cart_ram_size) ctx->cart_ram[addr] = val;
|
||||||
}
|
}
|
||||||
static void gb_error_handler(struct gb_s* gb, const enum gb_error_e err, const uint16_t addr) {
|
static void gb_error_handler(struct gb_s* gb, const enum gb_error_e err, const uint16_t addr) { (void)gb;(void)err;(void)addr; }
|
||||||
const char* err_str = "UNKNOWN";
|
|
||||||
switch (err) {
|
|
||||||
case GB_INVALID_OPCODE: err_str = "INVALID OPCODE"; break;
|
|
||||||
case GB_INVALID_READ: err_str = "INVALID READ"; break;
|
|
||||||
case GB_INVALID_WRITE: err_str = "INVALID WRITE"; break;
|
|
||||||
default: break;
|
|
||||||
}
|
|
||||||
ESP_LOGE(TAG, "GB error %s (%d) @ %04X", err_str, err, addr);
|
|
||||||
AppCtx* ctx = (AppCtx*)gb->direct.priv;
|
|
||||||
if (ctx && ctx->cart_ram_size) {
|
|
||||||
char sp[MAX_PATH];
|
|
||||||
strncpy(sp, ctx->rom_path, MAX_PATH-1); sp[MAX_PATH-1]='\0';
|
|
||||||
char* dot=strrchr(sp,'.'); char* sl=strrchr(sp,'/');
|
|
||||||
if (dot && (!sl || dot>sl)) snprintf(dot, MAX_PATH-(dot-sp), ".sav"); else strncat(sp,".sav",MAX_PATH-strlen(sp)-1);
|
|
||||||
FILE* f=fopen(sp,"wb"); if(f){fwrite(ctx->cart_ram,1,ctx->cart_ram_size,f); fclose(f);}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
static void lcd_draw_line(struct gb_s* gb, const uint8_t* pixels, const uint_fast8_t line) {
|
static void lcd_draw_line(struct gb_s* gb, const uint8_t* pixels, const uint_fast8_t line) {
|
||||||
AppCtx* ctx = (AppCtx*)gb->direct.priv;
|
AppCtx* ctx = (AppCtx*)gb->direct.priv;
|
||||||
if (!ctx || !ctx->fb_native) return;
|
if (!ctx || !ctx->fb_indexed) return;
|
||||||
if (line >= FRAME_H) return;
|
if (line >= FRAME_H) return;
|
||||||
uint16_t* dst = &ctx->fb_native[line * FRAME_W];
|
uint8_t* row = &ctx->fb_indexed[(line * FRAME_W) / 4];
|
||||||
for (int x=0;x<FRAME_W;x++) {
|
for (int x=0;x<FRAME_W;x++) {
|
||||||
uint8_t shade = pixels[x] & 0x03;
|
uint8_t shade = pixels[x] & 0x03;
|
||||||
dst[x] = GB_PALETTE[shade];
|
int byte_idx = x >> 2;
|
||||||
|
int shift = 6 - ((x & 0x03) * 2);
|
||||||
|
row[byte_idx] = (uint8_t)((row[byte_idx] & ~(0x03 << shift)) | ((shade & 0x03) << shift));
|
||||||
}
|
}
|
||||||
ctx->lines_drawn++;
|
ctx->lines_drawn++;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Save path */
|
|
||||||
static void get_save_path(const char* rom_path, char* out, size_t out_len) {
|
static void get_save_path(const char* rom_path, char* out, size_t out_len) {
|
||||||
if (!rom_path || !out) return;
|
if (!rom_path || !out) return;
|
||||||
strncpy(out, rom_path, out_len-1); out[out_len-1]='\0';
|
strncpy(out, rom_path, out_len-1); out[out_len-1]='\0';
|
||||||
@@ -184,16 +177,15 @@ static void load_cart_ram(AppCtx* ctx) {
|
|||||||
get_save_path(ctx->rom_path, save_path, sizeof(save_path));
|
get_save_path(ctx->rom_path, save_path, sizeof(save_path));
|
||||||
size_t save_sz=0;
|
size_t save_sz=0;
|
||||||
if (gb_get_save_size_s(&ctx->gb, &save_sz)!=0) save_sz=gb_get_save_size(&ctx->gb);
|
if (gb_get_save_size_s(&ctx->gb, &save_sz)!=0) save_sz=gb_get_save_size(&ctx->gb);
|
||||||
if (save_sz==0) { ESP_LOGI(TAG,"No save RAM"); return; }
|
if (save_sz==0) return;
|
||||||
if (ctx->cart_ram) { heap_caps_free(ctx->cart_ram); ctx->cart_ram=NULL; }
|
if (ctx->cart_ram) { heap_caps_free(ctx->cart_ram); ctx->cart_ram=NULL; }
|
||||||
ctx->cart_ram_size=save_sz;
|
ctx->cart_ram_size=save_sz;
|
||||||
ctx->cart_ram=alloc_psram(save_sz);
|
ctx->cart_ram=alloc_psram(save_sz);
|
||||||
if (!ctx->cart_ram){ ESP_LOGE(TAG,"cart RAM alloc fail %zu",save_sz); ctx->cart_ram_size=0; return; }
|
if (!ctx->cart_ram){ ctx->cart_ram_size=0; return; }
|
||||||
memset(ctx->cart_ram,0,save_sz);
|
memset(ctx->cart_ram,0,save_sz);
|
||||||
FILE* f=fopen(save_path,"rb");
|
FILE* f=fopen(save_path,"rb");
|
||||||
if (!f){ ESP_LOGI(TAG,"No save %s",save_path); return; }
|
if (!f) return;
|
||||||
size_t r=fread(ctx->cart_ram,1,save_sz,f); fclose(f);
|
fread(ctx->cart_ram,1,save_sz,f); fclose(f);
|
||||||
ESP_LOGI(TAG,"Loaded save %s %zu/%zu",save_path,r,save_sz);
|
|
||||||
}
|
}
|
||||||
static void save_cart_ram(AppCtx* ctx) {
|
static void save_cart_ram(AppCtx* ctx) {
|
||||||
if (!ctx || !ctx->cart_ram || ctx->cart_ram_size==0) return;
|
if (!ctx || !ctx->cart_ram || ctx->cart_ram_size==0) return;
|
||||||
@@ -201,23 +193,20 @@ static void save_cart_ram(AppCtx* ctx) {
|
|||||||
char save_path[MAX_PATH];
|
char save_path[MAX_PATH];
|
||||||
get_save_path(ctx->rom_path, save_path, sizeof(save_path));
|
get_save_path(ctx->rom_path, save_path, sizeof(save_path));
|
||||||
FILE* f=fopen(save_path,"wb");
|
FILE* f=fopen(save_path,"wb");
|
||||||
if (!f){ ESP_LOGE(TAG,"Save open fail %s",save_path); return; }
|
if (!f) return;
|
||||||
size_t w=fwrite(ctx->cart_ram,1,ctx->cart_ram_size,f); fclose(f);
|
fwrite(ctx->cart_ram,1,ctx->cart_ram_size,f); fclose(f);
|
||||||
ESP_LOGI(TAG,"Saved RAM %s %zu",save_path,w);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ROM loading */
|
|
||||||
static bool load_rom_file(AppCtx* ctx, const char* path) {
|
static bool load_rom_file(AppCtx* ctx, const char* path) {
|
||||||
if (!ctx || !path) return false;
|
if (!ctx || !path) return false;
|
||||||
ESP_LOGI(TAG,"Loading ROM %s",path);
|
|
||||||
FILE* f=fopen(path,"rb");
|
FILE* f=fopen(path,"rb");
|
||||||
if (!f){ ESP_LOGE(TAG,"Open fail %s",path); return false; }
|
if (!f) return false;
|
||||||
fseek(f,0,SEEK_END); long sz=ftell(f); fseek(f,0,SEEK_SET);
|
fseek(f,0,SEEK_END); long sz=ftell(f); fseek(f,0,SEEK_SET);
|
||||||
if (sz<=0 || sz>MAX_ROM_SIZE){ ESP_LOGE(TAG,"Bad size %ld %s",sz,path); fclose(f); return false; }
|
if (sz<=0 || sz>MAX_ROM_SIZE){ fclose(f); return false; }
|
||||||
uint8_t* buf=alloc_psram((size_t)sz);
|
uint8_t* buf=alloc_psram((size_t)sz);
|
||||||
if (!buf){ ESP_LOGE(TAG,"Alloc fail %ld",sz); fclose(f); return false; }
|
if (!buf){ fclose(f); return false; }
|
||||||
size_t read=fread(buf,1,(size_t)sz,f); fclose(f);
|
size_t read=fread(buf,1,(size_t)sz,f); fclose(f);
|
||||||
if (read!=(size_t)sz){ ESP_LOGE(TAG,"Short read %zu vs %ld",read,sz); heap_caps_free(buf); return false; }
|
if (read!=(size_t)sz){ heap_caps_free(buf); return false; }
|
||||||
|
|
||||||
if (ctx->rom_data) { if (ctx->cart_ram) save_cart_ram(ctx); heap_caps_free(ctx->rom_data); }
|
if (ctx->rom_data) { if (ctx->cart_ram) save_cart_ram(ctx); heap_caps_free(ctx->rom_data); }
|
||||||
if (ctx->cart_ram){ heap_caps_free(ctx->cart_ram); ctx->cart_ram=NULL; ctx->cart_ram_size=0; }
|
if (ctx->cart_ram){ heap_caps_free(ctx->cart_ram); ctx->cart_ram=NULL; ctx->cart_ram_size=0; }
|
||||||
@@ -227,35 +216,42 @@ static bool load_rom_file(AppCtx* ctx, const char* path) {
|
|||||||
memset(&ctx->gb,0,sizeof(ctx->gb));
|
memset(&ctx->gb,0,sizeof(ctx->gb));
|
||||||
ctx->joypad_state=0xFF;
|
ctx->joypad_state=0xFF;
|
||||||
enum gb_init_error_e err=gb_init(&ctx->gb, gb_rom_read, gb_cart_ram_read, gb_cart_ram_write, gb_error_handler, ctx);
|
enum gb_init_error_e err=gb_init(&ctx->gb, gb_rom_read, gb_cart_ram_read, gb_cart_ram_write, gb_error_handler, ctx);
|
||||||
if (err!=GB_INIT_NO_ERROR){ ESP_LOGE(TAG,"gb_init %d",err); heap_caps_free(ctx->rom_data); ctx->rom_data=NULL; ctx->rom_size=0; return false; }
|
if (err!=GB_INIT_NO_ERROR){ heap_caps_free(ctx->rom_data); ctx->rom_data=NULL; ctx->rom_size=0; return false; }
|
||||||
gb_init_lcd(&ctx->gb, lcd_draw_line);
|
gb_init_lcd(&ctx->gb, lcd_draw_line);
|
||||||
load_cart_ram(ctx);
|
load_cart_ram(ctx);
|
||||||
gb_reset(&ctx->gb);
|
gb_reset(&ctx->gb);
|
||||||
char title[32]={0}; gb_get_rom_name(&ctx->gb, title); strncpy(ctx->rom_title,title,sizeof(ctx->rom_title)-1);
|
char title[32]={0}; gb_get_rom_name(&ctx->gb, title); strncpy(ctx->rom_title,title,sizeof(ctx->rom_title)-1);
|
||||||
ESP_LOGI(TAG,"ROM OK title='%s' size=%zu save=%zu",ctx->rom_title,ctx->rom_size,ctx->cart_ram_size);
|
|
||||||
ctx->rom_loaded=true;
|
ctx->rom_loaded=true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
static void scan_rom_dir(AppCtx* ctx) {
|
static void scan_rom_dir(AppCtx* ctx) {
|
||||||
ctx->rom_count=0;
|
ctx->rom_count=0;
|
||||||
DIR* dir=opendir(ROMS_DIR);
|
for (size_t d=0; d < sizeof(ROMS_CANDIDATE_DIRS)/sizeof(ROMS_CANDIDATE_DIRS[0]) && ctx->rom_count < MAX_ROMS; d++) {
|
||||||
if (!dir){ ESP_LOGW(TAG,"ROMS dir missing %s",ROMS_DIR); return; }
|
const char* dir_path = ROMS_CANDIDATE_DIRS[d];
|
||||||
|
DIR* dir=opendir(dir_path);
|
||||||
|
if (!dir) continue;
|
||||||
struct dirent* ent;
|
struct dirent* ent;
|
||||||
while ((ent=readdir(dir))!=NULL && ctx->rom_count<MAX_ROMS){
|
while ((ent=readdir(dir))!=NULL && ctx->rom_count<MAX_ROMS){
|
||||||
if (ent->d_name[0]=='.') continue;
|
if (ent->d_name[0]=='.') continue;
|
||||||
size_t len=strlen(ent->d_name);
|
size_t len=strlen(ent->d_name);
|
||||||
if (len<3) continue;
|
if (len<3) continue;
|
||||||
bool is_gb = (strcasecmp(ent->d_name+len-3,".gb")==0) || (len>=4 && (strcasecmp(ent->d_name+len-4,".gbc")==0 || strcasecmp(ent->d_name+len-4,".bin")==0));
|
bool is_gb = (strcasecmp(ent->d_name+len-3,".gb")==0) || (len>=4 && (strcasecmp(ent->d_name+len-4,".gbc")==0 || strcasecmp(ent->d_name+len-4,".sgb")==0 || strcasecmp(ent->d_name+len-4,".bin")==0));
|
||||||
if (!is_gb) continue;
|
if (!is_gb) continue;
|
||||||
|
char full[MAX_PATH];
|
||||||
|
snprintf(full,sizeof(full),"%s/%s",dir_path,ent->d_name);
|
||||||
|
bool dup=false;
|
||||||
|
for(int i=0;i<ctx->rom_count;i++){
|
||||||
|
if (strcasecmp(ctx->roms[i].filename, ent->d_name)==0) { dup=true; break; }
|
||||||
|
}
|
||||||
|
if (dup) continue;
|
||||||
RomEntry* e=&ctx->roms[ctx->rom_count++];
|
RomEntry* e=&ctx->roms[ctx->rom_count++];
|
||||||
strncpy(e->filename, ent->d_name, sizeof(e->filename)-1); e->filename[sizeof(e->filename)-1]='\0';
|
strncpy(e->filename, ent->d_name, sizeof(e->filename)-1); e->filename[sizeof(e->filename)-1]='\0';
|
||||||
snprintf(e->fullpath, sizeof(e->fullpath), "%s/%s", ROMS_DIR, ent->d_name);
|
strncpy(e->fullpath, full, sizeof(e->fullpath)-1); e->fullpath[sizeof(e->fullpath)-1]='\0';
|
||||||
}
|
}
|
||||||
closedir(dir);
|
closedir(dir);
|
||||||
ESP_LOGI(TAG,"Found %d ROMs",ctx->rom_count);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Emu timer – THIS IS WHERE "FPS but no image" WAS: missing cache drop */
|
|
||||||
static void emu_timer_cb(lv_timer_t* timer) {
|
static void emu_timer_cb(lv_timer_t* timer) {
|
||||||
AppCtx* ctx=(AppCtx*)lv_timer_get_user_data(timer);
|
AppCtx* ctx=(AppCtx*)lv_timer_get_user_data(timer);
|
||||||
if (!ctx || !ctx->emu_running || !ctx->rom_loaded || !ctx->canvas) return;
|
if (!ctx || !ctx->emu_running || !ctx->rom_loaded || !ctx->canvas) return;
|
||||||
@@ -263,22 +259,19 @@ static void emu_timer_cb(lv_timer_t* timer) {
|
|||||||
ctx->gb.direct.joypad = ctx->joypad_state;
|
ctx->gb.direct.joypad = ctx->joypad_state;
|
||||||
gb_run_frame(&ctx->gb);
|
gb_run_frame(&ctx->gb);
|
||||||
|
|
||||||
/* Critical fix: raw buffer mutated, LVGL image cache is stale.
|
if (ctx->lines_drawn == 0) {
|
||||||
Without this, canvas shows whatever was first uploaded (gray/black) and FPS label keeps updating,
|
static uint32_t tick=0; tick++;
|
||||||
giving "FPS but no game". */
|
for (int y=0;y<FRAME_H;y++) {
|
||||||
if (ctx->fb_draw_buf) {
|
uint8_t* row=&ctx->fb_indexed[(y*FRAME_W)/4];
|
||||||
/* Invalidate both D-Cache (PSRAM) and LVGL image cache */
|
for (int x=0;x<FRAME_W;x++) {
|
||||||
lv_draw_buf_invalidate_cache(ctx->fb_draw_buf, NULL);
|
int bi=x>>2; int sh=6-((x&3)*2);
|
||||||
lv_image_cache_drop(ctx->fb_draw_buf);
|
uint8_t shade = (uint8_t)((x + y + tick) & 0x03);
|
||||||
/* Also invalidate area via the canvas src buf if different object */
|
row[bi] = (uint8_t)((row[bi] & ~(0x03<<sh)) | (shade<<sh));
|
||||||
if (ctx->canvas) {
|
|
||||||
lv_draw_buf_t* c_db = lv_canvas_get_draw_buf(ctx->canvas);
|
|
||||||
if (c_db && c_db != ctx->fb_draw_buf) {
|
|
||||||
lv_draw_buf_invalidate_cache(c_db, NULL);
|
|
||||||
lv_image_cache_drop(c_db);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ctx->fb_draw_buf) lv_image_cache_drop(ctx->fb_draw_buf);
|
||||||
lv_obj_invalidate(ctx->canvas);
|
lv_obj_invalidate(ctx->canvas);
|
||||||
|
|
||||||
ctx->fps_frames++;
|
ctx->fps_frames++;
|
||||||
@@ -287,31 +280,20 @@ static void emu_timer_cb(lv_timer_t* timer) {
|
|||||||
int64_t elapsed = now - ctx->fps_last_us;
|
int64_t elapsed = now - ctx->fps_last_us;
|
||||||
if (elapsed >= 1000000) {
|
if (elapsed >= 1000000) {
|
||||||
uint32_t fps = (uint32_t)((ctx->fps_frames * 1000000ULL) / (uint64_t)elapsed);
|
uint32_t fps = (uint32_t)((ctx->fps_frames * 1000000ULL) / (uint64_t)elapsed);
|
||||||
if (ctx->status_label) {
|
if (ctx->status_label) lv_label_set_text_fmt(ctx->status_label, "GB: %s FPS:%lu L:%lu", ctx->rom_title[0] ? ctx->rom_title : "GameBoy", (unsigned long)fps, (unsigned long)ctx->lines_drawn);
|
||||||
lv_label_set_text_fmt(ctx->status_label, "GB: %s FPS:%lu L:%lu", ctx->rom_title[0] ? ctx->rom_title : "GameBoy", (unsigned long)fps, (unsigned long)ctx->lines_drawn);
|
|
||||||
}
|
|
||||||
printf("GAMEBOY_FPS %lu LINES %lu\n", (unsigned long)fps, (unsigned long)ctx->lines_drawn);
|
printf("GAMEBOY_FPS %lu LINES %lu\n", (unsigned long)fps, (unsigned long)ctx->lines_drawn);
|
||||||
ctx->fps_frames = 0;
|
ctx->fps_frames = 0;
|
||||||
ctx->fps_last_us = now;
|
ctx->fps_last_us = now;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Input helpers */
|
|
||||||
static void set_joypad_bit(AppCtx* ctx, uint8_t bit, bool pressed) {
|
static void set_joypad_bit(AppCtx* ctx, uint8_t bit, bool pressed) {
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
if (pressed) ctx->joypad_state &= (uint8_t)~bit;
|
if (pressed) ctx->joypad_state &= (uint8_t)~bit;
|
||||||
else ctx->joypad_state |= bit;
|
else ctx->joypad_state |= bit;
|
||||||
}
|
}
|
||||||
static void input_down_cb(lv_event_t* e){
|
static void input_down_cb(lv_event_t* e){ BtnUserData* ud=(BtnUserData*)lv_event_get_user_data(e); if (!ud) return; set_joypad_bit(ud->ctx, ud->joypad_bit, true); }
|
||||||
BtnUserData* ud=(BtnUserData*)lv_event_get_user_data(e);
|
static void input_up_cb(lv_event_t* e){ BtnUserData* ud=(BtnUserData*)lv_event_get_user_data(e); if (!ud) return; set_joypad_bit(ud->ctx, ud->joypad_bit, false); }
|
||||||
if (!ud) return;
|
|
||||||
set_joypad_bit(ud->ctx, ud->joypad_bit, true);
|
|
||||||
}
|
|
||||||
static void input_up_cb(lv_event_t* e){
|
|
||||||
BtnUserData* ud=(BtnUserData*)lv_event_get_user_data(e);
|
|
||||||
if (!ud) return;
|
|
||||||
set_joypad_bit(ud->ctx, ud->joypad_bit, false);
|
|
||||||
}
|
|
||||||
static void key_press_cb(lv_event_t* e){
|
static void key_press_cb(lv_event_t* e){
|
||||||
AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e);
|
AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e);
|
||||||
uint32_t key=lv_event_get_key(e);
|
uint32_t key=lv_event_get_key(e);
|
||||||
@@ -330,13 +312,11 @@ static void key_press_cb(lv_event_t* e){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Forward declarations for UI switching */
|
|
||||||
static void build_browser_ui(AppCtx* ctx, lv_obj_t* parent);
|
static void build_browser_ui(AppCtx* ctx, lv_obj_t* parent);
|
||||||
static void build_emu_ui(AppCtx* ctx, lv_obj_t* parent);
|
static void build_emu_ui(AppCtx* ctx, lv_obj_t* parent);
|
||||||
static void switch_to_browser(AppCtx* ctx);
|
static void switch_to_browser(AppCtx* ctx);
|
||||||
static void switch_to_emu(AppCtx* ctx);
|
static void switch_to_emu(AppCtx* ctx);
|
||||||
|
|
||||||
/* ROM selection events */
|
|
||||||
static void rom_button_event(lv_event_t* e){
|
static void rom_button_event(lv_event_t* e){
|
||||||
AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e);
|
AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e);
|
||||||
lv_obj_t* btn=lv_event_get_target_obj(e);
|
lv_obj_t* btn=lv_event_get_target_obj(e);
|
||||||
@@ -345,51 +325,37 @@ static void rom_button_event(lv_event_t* e){
|
|||||||
ctx->selected_rom_idx=*pIdx;
|
ctx->selected_rom_idx=*pIdx;
|
||||||
if (ctx->selected_rom_idx<0 || ctx->selected_rom_idx>=ctx->rom_count) return;
|
if (ctx->selected_rom_idx<0 || ctx->selected_rom_idx>=ctx->rom_count) return;
|
||||||
const char* path=ctx->roms[ctx->selected_rom_idx].fullpath;
|
const char* path=ctx->roms[ctx->selected_rom_idx].fullpath;
|
||||||
if (load_rom_file(ctx, path)){
|
if (load_rom_file(ctx, path)) switch_to_emu(ctx);
|
||||||
switch_to_emu(ctx);
|
else if (ctx->status_label) lv_label_set_text_fmt(ctx->status_label, "Failed: %s", ctx->roms[ctx->selected_rom_idx].filename);
|
||||||
} else {
|
|
||||||
if (ctx->status_label) lv_label_set_text_fmt(ctx->status_label, "Failed: %s", ctx->roms[ctx->selected_rom_idx].filename);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
static void default_rom_event(lv_event_t* e){
|
static void default_rom_event(lv_event_t* e){
|
||||||
AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e);
|
AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e);
|
||||||
if (load_rom_file(ctx, DEFAULT_ROM_PATH)){
|
if (load_rom_file(ctx, DEFAULT_ROM_PATH)) switch_to_emu(ctx);
|
||||||
switch_to_emu(ctx);
|
else if (ctx->status_label) lv_label_set_text(ctx->status_label, "default.gb not found");
|
||||||
} else {
|
|
||||||
if (ctx->status_label) lv_label_set_text(ctx->status_label, "default.gb not found in /data/roms/gb/");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
static void back_to_menu_event(lv_event_t* e){
|
|
||||||
AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e);
|
|
||||||
switch_to_browser(ctx);
|
|
||||||
}
|
}
|
||||||
|
static void back_to_menu_event(lv_event_t* e){ AppCtx* ctx=(AppCtx*)lv_event_get_user_data(e); switch_to_browser(ctx); }
|
||||||
|
|
||||||
/* UI builders */
|
|
||||||
static void build_browser_ui(AppCtx* ctx, lv_obj_t* parent){
|
static void build_browser_ui(AppCtx* ctx, lv_obj_t* parent){
|
||||||
lv_obj_clean(parent);
|
lv_obj_clean(parent);
|
||||||
ctx->rom_list=NULL;
|
ctx->rom_list=NULL;
|
||||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||||
lv_obj_set_style_pad_all(parent, 4, 0);
|
lv_obj_set_style_pad_all(parent, 4, 0);
|
||||||
lv_obj_set_style_bg_color(parent, lv_color_black(), 0);
|
lv_obj_set_style_bg_color(parent, lv_color_black(), 0);
|
||||||
|
|
||||||
lv_obj_t* info=lv_label_create(parent);
|
lv_obj_t* info=lv_label_create(parent);
|
||||||
lv_label_set_text_fmt(info, "GameBoy (no audio) - Peanut-GB\nPlace ROMs in %s\nDefault: %s\nFound %d ROMs", ROMS_DIR, DEFAULT_ROM_PATH, ctx->rom_count);
|
lv_label_set_text_fmt(info, "GameBoy I2 fix – Place ROMs in Roms/GB – Found %d ROMs", ctx->rom_count);
|
||||||
lv_label_set_long_mode(info, LV_LABEL_LONG_WRAP);
|
lv_label_set_long_mode(info, LV_LABEL_LONG_WRAP);
|
||||||
lv_obj_set_width(info, LV_PCT(100));
|
lv_obj_set_width(info, LV_PCT(100));
|
||||||
lv_obj_set_style_text_color(info, lv_color_white(), 0);
|
lv_obj_set_style_text_color(info, lv_color_white(), 0);
|
||||||
|
|
||||||
ctx->status_label=lv_label_create(parent);
|
ctx->status_label=lv_label_create(parent);
|
||||||
lv_label_set_text(ctx->status_label, "Select a ROM to start");
|
lv_label_set_text(ctx->status_label, "Select a ROM – tap .gb in Files app also works");
|
||||||
lv_obj_set_style_text_color(ctx->status_label, lv_palette_main(LV_PALETTE_ORANGE), 0);
|
lv_obj_set_style_text_color(ctx->status_label, lv_palette_main(LV_PALETTE_ORANGE), 0);
|
||||||
|
|
||||||
lv_obj_t* list=lv_list_create(parent);
|
lv_obj_t* list=lv_list_create(parent);
|
||||||
lv_obj_set_width(list, LV_PCT(100));
|
lv_obj_set_width(list, LV_PCT(100));
|
||||||
lv_obj_set_flex_grow(list, 1);
|
lv_obj_set_flex_grow(list, 1);
|
||||||
ctx->rom_list=list;
|
ctx->rom_list=list;
|
||||||
|
|
||||||
if (ctx->rom_count==0){
|
if (ctx->rom_count==0){
|
||||||
lv_obj_t* lbl=lv_label_create(parent);
|
lv_obj_t* lbl=lv_label_create(parent);
|
||||||
lv_label_set_text(lbl, "No ROMs found in /data/roms/gb\nPut .gb files there.");
|
lv_label_set_text(lbl, "No ROMs. Put .gb in /sdcard/Roms/GB");
|
||||||
lv_obj_set_style_text_color(lbl, lv_color_white(), 0);
|
lv_obj_set_style_text_color(lbl, lv_color_white(), 0);
|
||||||
} else {
|
} else {
|
||||||
for (int i=0;i<ctx->rom_count;i++){
|
for (int i=0;i<ctx->rom_count;i++){
|
||||||
@@ -399,7 +365,6 @@ static void build_browser_ui(AppCtx* ctx, lv_obj_t* parent){
|
|||||||
lv_obj_add_event_cb(btn, rom_button_event, LV_EVENT_CLICKED, ctx);
|
lv_obj_add_event_cb(btn, rom_button_event, LV_EVENT_CLICKED, ctx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lv_obj_t* default_btn=lv_btn_create(parent);
|
lv_obj_t* default_btn=lv_btn_create(parent);
|
||||||
lv_obj_set_width(default_btn, LV_PCT(100));
|
lv_obj_set_width(default_btn, LV_PCT(100));
|
||||||
lv_obj_t* dlbl=lv_label_create(default_btn);
|
lv_obj_t* dlbl=lv_label_create(default_btn);
|
||||||
@@ -449,18 +414,13 @@ static void build_emu_ui(AppCtx* ctx, lv_obj_t* parent){
|
|||||||
lv_obj_set_flex_align(canvas_cont, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
lv_obj_set_flex_align(canvas_cont, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||||
lv_obj_remove_flag(canvas_cont, LV_OBJ_FLAG_SCROLLABLE);
|
lv_obj_remove_flag(canvas_cont, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
if (!ctx->fb_native){
|
if (!ctx->fb_indexed){
|
||||||
size_t fb_bytes=FRAME_W*FRAME_H*sizeof(uint16_t);
|
size_t fb_bytes = (FRAME_W * FRAME_H) / 4;
|
||||||
ctx->fb_native=(uint16_t*)alloc_psram(fb_bytes);
|
ctx->fb_indexed=(uint8_t*)alloc_internal(fb_bytes);
|
||||||
if (ctx->fb_native){
|
if (ctx->fb_indexed){ ctx->framebuffer_allocated=true; memset(ctx->fb_indexed,0,fb_bytes); }
|
||||||
ctx->framebuffer_allocated=true;
|
|
||||||
memset(ctx->fb_native,0,fb_bytes);
|
|
||||||
/* Initial grey fill so we can visually confirm buffer ownership even before first gb_run_frame */
|
|
||||||
for(int i=0;i<FRAME_W*FRAME_H;i++) ctx->fb_native[i]=0x4208;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ctx->fb_native){
|
if (ctx->fb_indexed){
|
||||||
lv_coord_t disp_w = lv_display_get_horizontal_resolution(NULL);
|
lv_coord_t disp_w = lv_display_get_horizontal_resolution(NULL);
|
||||||
lv_coord_t disp_h = lv_display_get_vertical_resolution(NULL);
|
lv_coord_t disp_h = lv_display_get_vertical_resolution(NULL);
|
||||||
int avail_h = disp_h - 160;
|
int avail_h = disp_h - 160;
|
||||||
@@ -470,21 +430,23 @@ static void build_emu_ui(AppCtx* ctx, lv_obj_t* parent){
|
|||||||
else if (avail_w >= FRAME_W * 2 && avail_h >= FRAME_H * 2) scale = 2;
|
else if (avail_w >= FRAME_W * 2 && avail_h >= FRAME_H * 2) scale = 2;
|
||||||
|
|
||||||
ctx->canvas = lv_canvas_create(canvas_cont);
|
ctx->canvas = lv_canvas_create(canvas_cont);
|
||||||
/* lv_canvas_set_buffer creates internal static_buf header from our raw ptr */
|
lv_canvas_set_buffer(ctx->canvas, ctx->fb_indexed, FRAME_W, FRAME_H, LV_COLOR_FORMAT_I2);
|
||||||
lv_canvas_set_buffer(ctx->canvas, ctx->fb_native, FRAME_W, FRAME_H, LV_COLOR_FORMAT_RGB565);
|
lv_color32_t c0 = { .red=0xFF, .green=0xFF, .blue=0xFF, .alpha=0xFF };
|
||||||
|
lv_color32_t c1 = { .red=0xAA, .green=0xAA, .blue=0xAA, .alpha=0xFF };
|
||||||
|
lv_color32_t c2 = { .red=0x55, .green=0x55, .blue=0x55, .alpha=0xFF };
|
||||||
|
lv_color32_t c3 = { .red=0x00, .green=0x00, .blue=0x00, .alpha=0xFF };
|
||||||
|
lv_canvas_set_palette(ctx->canvas, 0, c0);
|
||||||
|
lv_canvas_set_palette(ctx->canvas, 1, c1);
|
||||||
|
lv_canvas_set_palette(ctx->canvas, 2, c2);
|
||||||
|
lv_canvas_set_palette(ctx->canvas, 3, c3);
|
||||||
ctx->fb_draw_buf = lv_canvas_get_draw_buf(ctx->canvas);
|
ctx->fb_draw_buf = lv_canvas_get_draw_buf(ctx->canvas);
|
||||||
if (ctx->fb_draw_buf && ctx->fb_draw_buf->data) {
|
if (ctx->fb_draw_buf) lv_image_cache_drop(ctx->fb_draw_buf);
|
||||||
/* Force fresh cache state */
|
|
||||||
lv_draw_buf_invalidate_cache(ctx->fb_draw_buf, NULL);
|
|
||||||
lv_image_cache_drop(ctx->fb_draw_buf);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scale > 1) {
|
if (scale > 1) {
|
||||||
lv_image_set_scale(ctx->canvas, (uint32_t)(256 * scale));
|
lv_image_set_scale(ctx->canvas, (uint32_t)(256 * scale));
|
||||||
lv_image_set_pivot(ctx->canvas, FRAME_W / 2, FRAME_H / 2);
|
lv_image_set_pivot(ctx->canvas, FRAME_W / 2, FRAME_H / 2);
|
||||||
}
|
}
|
||||||
lv_obj_set_style_border_width(ctx->canvas, 1, 0);
|
lv_obj_set_style_border_width(ctx->canvas, 1, 0);
|
||||||
lv_obj_set_style_border_color(ctx->canvas, lv_color_hex(0x444444), 0);
|
lv_obj_set_style_border_color(ctx->canvas, lv_color_hex(0x00FF00), 0);
|
||||||
lv_obj_center(ctx->canvas);
|
lv_obj_center(ctx->canvas);
|
||||||
} else {
|
} else {
|
||||||
lv_obj_t* err=lv_label_create(canvas_cont); lv_label_set_text(err,"FB alloc failed"); lv_obj_set_style_text_color(err, lv_color_white(),0);
|
lv_obj_t* err=lv_label_create(canvas_cont); lv_label_set_text(err,"FB alloc failed"); lv_obj_set_style_text_color(err, lv_color_white(),0);
|
||||||
@@ -509,7 +471,6 @@ static void build_emu_ui(AppCtx* ctx, lv_obj_t* parent){
|
|||||||
lv_obj_set_style_bg_opa(dpad,LV_OPA_TRANSP,0);
|
lv_obj_set_style_bg_opa(dpad,LV_OPA_TRANSP,0);
|
||||||
lv_obj_set_style_border_width(dpad,0,0);
|
lv_obj_set_style_border_width(dpad,0,0);
|
||||||
lv_obj_set_style_pad_all(dpad,0,0);
|
lv_obj_set_style_pad_all(dpad,0,0);
|
||||||
|
|
||||||
lv_obj_t* up=lv_btn_create(dpad); lv_obj_set_size(up,32,32); lv_obj_set_pos(up,32,0);
|
lv_obj_t* up=lv_btn_create(dpad); lv_obj_set_size(up,32,32); lv_obj_set_pos(up,32,0);
|
||||||
lv_obj_t* left=lv_btn_create(dpad); lv_obj_set_size(left,32,32); lv_obj_set_pos(left,0,32);
|
lv_obj_t* left=lv_btn_create(dpad); lv_obj_set_size(left,32,32); lv_obj_set_pos(left,0,32);
|
||||||
lv_obj_t* right=lv_btn_create(dpad); lv_obj_set_size(right,32,32); lv_obj_set_pos(right,64,32);
|
lv_obj_t* right=lv_btn_create(dpad); lv_obj_set_size(right,32,32); lv_obj_set_pos(right,64,32);
|
||||||
@@ -518,7 +479,6 @@ static void build_emu_ui(AppCtx* ctx, lv_obj_t* parent){
|
|||||||
lbl=lv_label_create(down); lv_label_set_text(lbl, LV_SYMBOL_DOWN); lv_obj_center(lbl);
|
lbl=lv_label_create(down); lv_label_set_text(lbl, LV_SYMBOL_DOWN); lv_obj_center(lbl);
|
||||||
lbl=lv_label_create(left); lv_label_set_text(lbl, LV_SYMBOL_LEFT); lv_obj_center(lbl);
|
lbl=lv_label_create(left); lv_label_set_text(lbl, LV_SYMBOL_LEFT); lv_obj_center(lbl);
|
||||||
lbl=lv_label_create(right); lv_label_set_text(lbl, LV_SYMBOL_RIGHT); lv_obj_center(lbl);
|
lbl=lv_label_create(right); lv_label_set_text(lbl, LV_SYMBOL_RIGHT); lv_obj_center(lbl);
|
||||||
|
|
||||||
ctx->btn_up=up; ctx->btn_down=down; ctx->btn_left=left; ctx->btn_right=right;
|
ctx->btn_up=up; ctx->btn_down=down; ctx->btn_left=left; ctx->btn_right=right;
|
||||||
btn_ud[0].ctx=ctx; btn_ud[0].joypad_bit=JOYPAD_UP; lv_obj_add_event_cb(up, input_down_cb, LV_EVENT_PRESSED, &btn_ud[0]); lv_obj_add_event_cb(up, input_up_cb, LV_EVENT_RELEASED, &btn_ud[0]); lv_obj_add_event_cb(up, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[0]);
|
btn_ud[0].ctx=ctx; btn_ud[0].joypad_bit=JOYPAD_UP; lv_obj_add_event_cb(up, input_down_cb, LV_EVENT_PRESSED, &btn_ud[0]); lv_obj_add_event_cb(up, input_up_cb, LV_EVENT_RELEASED, &btn_ud[0]); lv_obj_add_event_cb(up, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[0]);
|
||||||
btn_ud[1].ctx=ctx; btn_ud[1].joypad_bit=JOYPAD_DOWN; lv_obj_add_event_cb(down, input_down_cb, LV_EVENT_PRESSED, &btn_ud[1]); lv_obj_add_event_cb(down, input_up_cb, LV_EVENT_RELEASED, &btn_ud[1]); lv_obj_add_event_cb(down, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[1]);
|
btn_ud[1].ctx=ctx; btn_ud[1].joypad_bit=JOYPAD_DOWN; lv_obj_add_event_cb(down, input_down_cb, LV_EVENT_PRESSED, &btn_ud[1]); lv_obj_add_event_cb(down, input_up_cb, LV_EVENT_RELEASED, &btn_ud[1]); lv_obj_add_event_cb(down, input_up_cb, LV_EVENT_PRESS_LOST, &btn_ud[1]);
|
||||||
@@ -532,7 +492,6 @@ static void build_emu_ui(AppCtx* ctx, lv_obj_t* parent){
|
|||||||
lv_obj_set_flex_flow(center_col, LV_FLEX_FLOW_COLUMN);
|
lv_obj_set_flex_flow(center_col, LV_FLEX_FLOW_COLUMN);
|
||||||
lv_obj_set_flex_align(center_col, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
lv_obj_set_flex_align(center_col, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||||
lv_obj_set_style_pad_row(center_col,4,0);
|
lv_obj_set_style_pad_row(center_col,4,0);
|
||||||
|
|
||||||
lv_obj_t* sel_btn=lv_btn_create(center_col); lv_obj_set_size(sel_btn,60,28); lbl=lv_label_create(sel_btn); lv_label_set_text(lbl,"Sel"); lv_obj_center(lbl);
|
lv_obj_t* sel_btn=lv_btn_create(center_col); lv_obj_set_size(sel_btn,60,28); lbl=lv_label_create(sel_btn); lv_label_set_text(lbl,"Sel"); lv_obj_center(lbl);
|
||||||
lv_obj_t* sta_btn=lv_btn_create(center_col); lv_obj_set_size(sta_btn,60,28); lbl=lv_label_create(sta_btn); lv_label_set_text(lbl,"Sta"); lv_obj_center(lbl);
|
lv_obj_t* sta_btn=lv_btn_create(center_col); lv_obj_set_size(sta_btn,60,28); lbl=lv_label_create(sta_btn); lv_label_set_text(lbl,"Sta"); lv_obj_center(lbl);
|
||||||
ctx->btn_select=sel_btn; ctx->btn_start=sta_btn;
|
ctx->btn_select=sel_btn; ctx->btn_start=sta_btn;
|
||||||
@@ -586,7 +545,6 @@ static void switch_to_emu(AppCtx* ctx){
|
|||||||
build_emu_ui(ctx, ctx->emu_wrapper);
|
build_emu_ui(ctx, ctx->emu_wrapper);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Lifecycle */
|
|
||||||
static void* create_data(void){
|
static void* create_data(void){
|
||||||
AppCtx* ctx=(AppCtx*)calloc(1,sizeof(AppCtx));
|
AppCtx* ctx=(AppCtx*)calloc(1,sizeof(AppCtx));
|
||||||
if (ctx){ ctx->joypad_state=0xFF; ctx->mode=APP_MODE_BROWSER; ctx->selected_rom_idx=-1; }
|
if (ctx){ ctx->joypad_state=0xFF; ctx->mode=APP_MODE_BROWSER; ctx->selected_rom_idx=-1; }
|
||||||
@@ -596,14 +554,12 @@ static void destroy_data(void* data){
|
|||||||
AppCtx* ctx=(AppCtx*)data;
|
AppCtx* ctx=(AppCtx*)data;
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
if (ctx->emu_timer) lv_timer_delete(ctx->emu_timer);
|
if (ctx->emu_timer) lv_timer_delete(ctx->emu_timer);
|
||||||
if (ctx->fb_native) heap_caps_free(ctx->fb_native);
|
if (ctx->fb_indexed) heap_caps_free(ctx->fb_indexed);
|
||||||
if (ctx->rom_data) heap_caps_free(ctx->rom_data);
|
if (ctx->rom_data) heap_caps_free(ctx->rom_data);
|
||||||
if (ctx->cart_ram) heap_caps_free(ctx->cart_ram);
|
if (ctx->cart_ram) heap_caps_free(ctx->cart_ram);
|
||||||
free(ctx);
|
free(ctx);
|
||||||
}
|
}
|
||||||
static void on_create(AppHandle app, void* data){
|
static void on_create(AppHandle app, void* data){ AppCtx* ctx=(AppCtx*)data; if (ctx) ctx->app_handle=app; }
|
||||||
AppCtx* ctx=(AppCtx*)data; if (ctx) ctx->app_handle=app;
|
|
||||||
}
|
|
||||||
static void on_destroy(AppHandle app, void* data){ (void)app;(void)data; }
|
static void on_destroy(AppHandle app, void* data){ (void)app;(void)data; }
|
||||||
static void on_show(AppHandle app, void* data, lv_obj_t* parent){
|
static void on_show(AppHandle app, void* data, lv_obj_t* parent){
|
||||||
AppCtx* ctx=(AppCtx*)data; if (!ctx) return;
|
AppCtx* ctx=(AppCtx*)data; if (!ctx) return;
|
||||||
@@ -638,11 +594,47 @@ static void on_show(AppHandle app, void* data, lv_obj_t* parent){
|
|||||||
lv_obj_set_style_border_width(ctx->emu_wrapper,0,0);
|
lv_obj_set_style_border_width(ctx->emu_wrapper,0,0);
|
||||||
lv_obj_add_flag(ctx->emu_wrapper, LV_OBJ_FLAG_HIDDEN);
|
lv_obj_add_flag(ctx->emu_wrapper, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
|
||||||
scan_rom_dir(ctx);
|
BundleHandle bundle = tt_app_get_parameters(app);
|
||||||
|
char file_param[512] = {0};
|
||||||
|
bool hasFileParam = false;
|
||||||
|
const char* file_to_try = NULL;
|
||||||
|
if (bundle && tt_bundle_opt_string(bundle, "file", file_param, sizeof(file_param))) {
|
||||||
|
hasFileParam = file_param[0] != '\0';
|
||||||
|
file_to_try = file_param;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasFileParam && file_to_try) {
|
||||||
|
char resolved[MAX_PATH];
|
||||||
|
#pragma GCC diagnostic push
|
||||||
|
#pragma GCC diagnostic ignored "-Wformat-truncation"
|
||||||
|
if (strncmp(file_to_try, "/sdcard", 7) == 0 || strncmp(file_to_try, "/data", 5) == 0) {
|
||||||
|
snprintf(resolved, sizeof(resolved), "%s", file_to_try);
|
||||||
|
} else if (file_to_try[0] == '/') {
|
||||||
|
snprintf(resolved, sizeof(resolved), "/sdcard%s", file_to_try);
|
||||||
|
} else {
|
||||||
|
char truncated_file[400];
|
||||||
|
snprintf(truncated_file, sizeof(truncated_file), "%s", file_to_try);
|
||||||
|
snprintf(resolved, sizeof(resolved), "/sdcard/%s", truncated_file);
|
||||||
|
}
|
||||||
|
#pragma GCC diagnostic pop
|
||||||
struct stat st;
|
struct stat st;
|
||||||
bool default_exists=(stat(DEFAULT_ROM_PATH,&st)==0);
|
if (stat(resolved, &st)==0 && load_rom_file(ctx, resolved)) {
|
||||||
if (default_exists){
|
build_browser_ui(ctx, ctx->browser_wrapper);
|
||||||
if (load_rom_file(ctx, DEFAULT_ROM_PATH)){
|
switch_to_emu(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (load_rom_file(ctx, file_to_try)) {
|
||||||
|
build_browser_ui(ctx, ctx->browser_wrapper);
|
||||||
|
switch_to_emu(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scan_rom_dir(ctx);
|
||||||
|
const char* default_candidates[] = { DEFAULT_ROM_PATH_1, DEFAULT_ROM_PATH_2, DEFAULT_ROM_PATH_3, DEFAULT_ROM_PATH_4, ROMS_DIR_LEGACY "/default.gb" };
|
||||||
|
for (size_t i=0;i<sizeof(default_candidates)/sizeof(default_candidates[0]);i++) {
|
||||||
|
struct stat st;
|
||||||
|
if (stat(default_candidates[i], &st)==0 && load_rom_file(ctx, default_candidates[i])) {
|
||||||
build_browser_ui(ctx, ctx->browser_wrapper);
|
build_browser_ui(ctx, ctx->browser_wrapper);
|
||||||
switch_to_emu(ctx);
|
switch_to_emu(ctx);
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user