add flac support and files management app
This commit is contained in:
1 parent
4126ea13a1
commit
45f7b83e7c
12 files changed
+1526
-139
No files matched your search
+4
-4
@@ -41,7 +41,7 @@ static const app_entry_t s_apps[] = {
|
||||
{
|
||||
.icon = LV_SYMBOL_AUDIO, /* 音符:音乐播放器 */
|
||||
.title = "MUSIC",
|
||||
.subtitle = "WAV files in SD /MUSIC",
|
||||
.subtitle = "WAV / FLAC files in SD /MUSIC",
|
||||
.mode = APP_MODE_PLAYER,
|
||||
.enabled = true,
|
||||
.color_from = 0x8E5BFF,
|
||||
@@ -50,9 +50,9 @@ static const app_entry_t s_apps[] = {
|
||||
{
|
||||
.icon = LV_SYMBOL_DIRECTORY, /* 文件夹:SD 卡文件浏览 */
|
||||
.title = "FILES",
|
||||
.subtitle = "Browse files on SD card",
|
||||
.mode = APP_MODE_NONE,
|
||||
.enabled = false,
|
||||
.subtitle = "Browse every file on SD card",
|
||||
.mode = APP_MODE_FILES,
|
||||
.enabled = true,
|
||||
.color_from = 0x31C27C,
|
||||
.color_to = 0x1E9C6B,
|
||||
},
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
* K0 短按 = 返回主界面(主界面时为切换下一个应用)
|
||||
* K0 长按 = 直接切换到下一个应用
|
||||
* K1/K2/K3 = 由各 App 自己定义
|
||||
*
|
||||
* 例外:实现了 ui_xxx_back() 的 App(如文件管理器)可以先用 K0 短按
|
||||
* 完成“就地返回”(返回上一级目录),只有到顶层时才回主界面。
|
||||
****************************************************************************************************
|
||||
*/
|
||||
|
||||
@@ -35,6 +38,7 @@ extern "C" {
|
||||
typedef enum {
|
||||
APP_MODE_HOME = 0, /* 主界面:应用启动器 */
|
||||
APP_MODE_PLAYER, /* 音乐播放器 */
|
||||
APP_MODE_FILES, /* 文件管理器:浏览 SD 卡目录 */
|
||||
APP_MODE_NUM, /* 模式总数(新增应用请加在这之前) */
|
||||
} app_mode_t;
|
||||
|
||||
|
||||
@@ -0,0 +1,863 @@
|
||||
/**
|
||||
****************************************************************************************************
|
||||
* @file ui_files.c
|
||||
* @author HiFi Player Project
|
||||
* @version V1.0
|
||||
* @date 2026-01-01
|
||||
* @brief 文件管理器 —— 浏览 SD 卡上的全部文件,支持进出子文件夹
|
||||
*
|
||||
* ┌──────────────────────────────────┐
|
||||
* │ 📁 FILES 12 SD │ 状态栏
|
||||
* ├──────────────────────────────────┤
|
||||
* │ 0:/MUSIC/Album │ 当前路径
|
||||
* ├──────────────────────────────────┤
|
||||
* │ 📁 .. <DIR> │ 选中行(高亮 + 描边)
|
||||
* │ 📁 Album <DIR> │
|
||||
* │ ♪ song01.flac 24.1M │ 目录在前,文件在后,按名称排序
|
||||
* │ 📄 notes.txt 128B │
|
||||
* │ 📄 readme.md 2K │
|
||||
* ├──────────────────────────────────┤
|
||||
* │ [◀K0] [▲K1] [▼K2] [✔K3] │
|
||||
* │ BACK UP DOWN OPEN │
|
||||
* └──────────────────────────────────┘
|
||||
*
|
||||
* 实现要点:
|
||||
* 1. 不重建控件。界面创建时固定做 FILES_ROWS 个“行”,刷新时把
|
||||
* [s_top, s_top+FILES_ROWS) 的条目映射到这些行上(滑动窗口),
|
||||
* 即使目录里有几百个文件也不会撑爆 LVGL 内存。
|
||||
* 2. 目录里超过 FILES_MAX_ENTRIES 的条目会被截断,避免静态数组过大。
|
||||
* 3. 非根目录时列表第一行固定是 "..",回车即可返回上级;
|
||||
* K0 短按同样用于返回上级(根目录时由 ui.c 接管回主界面)。
|
||||
* 4. 中文文件名是 GBK 编码,LVGL 内置字体无法显示,用 '?' 逐字占位,
|
||||
* 但进入目录 / 打开文件仍使用原始字节,不影响功能。
|
||||
****************************************************************************************************
|
||||
*/
|
||||
|
||||
#include "ui_files.h"
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <strings.h>
|
||||
|
||||
#include "esp_log.h"
|
||||
|
||||
#include "ff.h"
|
||||
|
||||
#include "spi_sd.h"
|
||||
|
||||
#include "ui_common.h"
|
||||
#include "app.h"
|
||||
|
||||
#define TAG "ui_files"
|
||||
|
||||
/* ---------------- 浏览参数 ---------------- */
|
||||
#define FILES_ROOT "0:" /* SD 卡挂载点下的根目录 */
|
||||
#define FILES_MAX_ENTRIES 96 /* 单目录最多显示多少条 */
|
||||
#define FILES_NAME_MAX 96 /* 单条文件名缓冲 */
|
||||
|
||||
#define FILES_ROWS 5 /* 列表可见行数 */
|
||||
#define FILES_ROW_H 28 /* 每行高度 */
|
||||
|
||||
#define FILES_PATH_MAX 300 /* 当前路径缓冲 */
|
||||
|
||||
/* ---------------- 屏幕布局 ---------------- */
|
||||
/* 注意:不能用 LIST_H 这类名字,FreeRTOS 的 list.h 用 LIST_H 作为 include guard */
|
||||
#define FILES_HEADER_H 30
|
||||
#define FILES_PATH_Y 32
|
||||
#define FILES_PATH_H 20
|
||||
#define FILES_LIST_Y 54
|
||||
#define FILES_LIST_H (FILES_ROWS * FILES_ROW_H) /* 140 → 底边 194 */
|
||||
#define FILES_TOAST_Y 150
|
||||
|
||||
/* 每行内部的横向布局 */
|
||||
#define ROW_NAME_X 22 /* 名称相对行左边的偏移 */
|
||||
#define ROW_NAME_W 224 /* 名称最大宽度(超出滚动) */
|
||||
#define ROW_SIZE_W 44 /* 右侧大小 / <DIR> 宽度 */
|
||||
|
||||
/* FATFS 卷标属性(本版本 ff.h 未提供 AM_VOL) */
|
||||
#define FILES_AM_VOL 0x08
|
||||
|
||||
/* ---------------- 条目 ---------------- */
|
||||
typedef struct {
|
||||
char name[FILES_NAME_MAX]; /* 原始文件 / 目录名(GBK 原样保留) */
|
||||
uint32_t size; /* 文件字节数,目录为 0 */
|
||||
bool is_dir;
|
||||
} files_entry_t;
|
||||
|
||||
/* ---------------- 行控件(固定池) ---------------- */
|
||||
typedef struct {
|
||||
lv_obj_t *row;
|
||||
lv_obj_t *icon;
|
||||
lv_obj_t *name;
|
||||
lv_obj_t *size;
|
||||
} files_row_t;
|
||||
|
||||
/* ---------------- 界面控件 ---------------- */
|
||||
static lv_obj_t *s_scr = NULL;
|
||||
static lv_obj_t *s_lbl_path = NULL;
|
||||
static lv_obj_t *s_lbl_count = NULL;
|
||||
static lv_obj_t *s_lbl_empty = NULL;
|
||||
static lv_obj_t *s_toast = NULL;
|
||||
static lv_obj_t *s_toast_lbl = NULL;
|
||||
|
||||
static files_row_t s_rows[FILES_ROWS];
|
||||
|
||||
static ui_key_bar_t s_bar;
|
||||
static ui_flash_t s_flash;
|
||||
|
||||
/* ---------------- 浏览状态 ---------------- */
|
||||
static files_entry_t s_entries[FILES_MAX_ENTRIES];
|
||||
static int s_count = 0; /* 有效条目数 */
|
||||
static int s_sel = 0; /* 选中项下标 */
|
||||
static int s_top = 0; /* 列表窗口首行对应的条目下标 */
|
||||
static char s_path[FILES_PATH_MAX] = FILES_ROOT;
|
||||
|
||||
static FRESULT s_load_res = FR_OK; /* 最近一次目录读取结果 */
|
||||
static bool s_sd_ok = false; /* SD 卡是否已可读 */
|
||||
static bool s_mount_tried = false; /* 是否已经尝试过挂载 */
|
||||
static uint32_t s_toast_tick = 0;
|
||||
|
||||
/* ---------------- 按键提示 ---------------- */
|
||||
static const char *const s_symbols[APP_KEY_NUM] = {
|
||||
LV_SYMBOL_LEFT, /* K0:返回上级目录 / 回主界面 */
|
||||
LV_SYMBOL_UP, /* K1:上一项 */
|
||||
LV_SYMBOL_DOWN, /* K2:下一项 */
|
||||
LV_SYMBOL_OK, /* K3:进入目录 */
|
||||
};
|
||||
|
||||
static const char *const s_captions[APP_KEY_NUM] = {
|
||||
"K0 BACK",
|
||||
"K1 UP",
|
||||
"K2 DOWN",
|
||||
"K3 OPEN",
|
||||
};
|
||||
|
||||
static const bool s_enabled[APP_KEY_NUM] = { true, true, true, true };
|
||||
|
||||
/* ================================================================================================
|
||||
* 小工具
|
||||
* ============================================================================================== */
|
||||
|
||||
/**
|
||||
* @brief GBK 文件名 → 可在 LVGL 内置字体上显示的字符串
|
||||
* @note ASCII 原样保留;每个双字节汉字折叠成一个 '?',避免出现乱码方块
|
||||
*/
|
||||
static void files_display_name(const char *src, char *dst, size_t size)
|
||||
{
|
||||
size_t len = strlen(src);
|
||||
size_t j = 0;
|
||||
|
||||
if (size == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (size_t i = 0; (i < len) && (j + 1 < size); i++)
|
||||
{
|
||||
unsigned char c = (unsigned char)src[i];
|
||||
|
||||
if ((c >= 0x20) && (c < 0x7F))
|
||||
{
|
||||
dst[j++] = (char)c;
|
||||
continue;
|
||||
}
|
||||
|
||||
/* GBK 双字节字符:跳过第二个字节,只输出一个占位符 */
|
||||
if ((c >= 0x81) && (i + 1 < len))
|
||||
{
|
||||
unsigned char t = (unsigned char)src[i + 1];
|
||||
|
||||
if ((t >= 0x40) && (t <= 0xFE))
|
||||
{
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
dst[j++] = '?';
|
||||
}
|
||||
|
||||
dst[j] = '\0';
|
||||
}
|
||||
|
||||
/** 文件大小 → "128B" / "2K" / "24.1M"(纯整数运算,不依赖浮点 printf) */
|
||||
static void files_format_size(uint32_t bytes, char *out, size_t size)
|
||||
{
|
||||
const uint32_t mb = 1024u * 1024u;
|
||||
|
||||
if (bytes >= mb)
|
||||
{
|
||||
uint32_t whole = bytes / mb;
|
||||
uint32_t frac = ((bytes % mb) * 10u) / mb;
|
||||
|
||||
snprintf(out, size, "%u.%uM", (unsigned)whole, (unsigned)frac);
|
||||
}
|
||||
else if (bytes >= 1024u)
|
||||
{
|
||||
snprintf(out, size, "%uK", (unsigned)(bytes / 1024u));
|
||||
}
|
||||
else
|
||||
{
|
||||
snprintf(out, size, "%uB", (unsigned)bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/** 冒烟测试用的扩展名判断(只影响图标和颜色) */
|
||||
static bool files_name_has_ext(const char *name, const char *ext)
|
||||
{
|
||||
size_t nlen = strlen(name);
|
||||
size_t elen = strlen(ext);
|
||||
|
||||
if (nlen <= elen)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const char *tail = name + nlen - elen;
|
||||
|
||||
for (size_t i = 0; i < elen; i++)
|
||||
{
|
||||
char a = tail[i];
|
||||
char b = ext[i];
|
||||
|
||||
if ((a >= 'A') && (a <= 'Z'))
|
||||
{
|
||||
a = (char)(a - 'A' + 'a');
|
||||
}
|
||||
|
||||
if ((b >= 'A') && (b <= 'Z'))
|
||||
{
|
||||
b = (char)(b - 'A' + 'a');
|
||||
}
|
||||
|
||||
if (a != b)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool files_is_audio(const char *name)
|
||||
{
|
||||
return files_name_has_ext(name, ".wav") ||
|
||||
files_name_has_ext(name, ".flac");
|
||||
}
|
||||
|
||||
static bool files_is_image(const char *name)
|
||||
{
|
||||
return files_name_has_ext(name, ".bmp") ||
|
||||
files_name_has_ext(name, ".png") ||
|
||||
files_name_has_ext(name, ".jpg") ||
|
||||
files_name_has_ext(name, ".jpeg") ||
|
||||
files_name_has_ext(name, ".gif");
|
||||
}
|
||||
|
||||
/* ================================================================================================
|
||||
* SD 卡
|
||||
* ============================================================================================== */
|
||||
|
||||
/**
|
||||
* @brief 确保 SD 卡已挂载
|
||||
* @note 正常情况下播放器任务在开机时已经挂载好,这里只做“探测”,
|
||||
* 只有探测失败且从未尝试过时才主动挂载一次,避免和播放器争抢。
|
||||
*/
|
||||
static void files_ensure_mounted(void)
|
||||
{
|
||||
FF_DIR dir;
|
||||
|
||||
if (f_opendir(&dir, FILES_ROOT) == FR_OK)
|
||||
{
|
||||
f_closedir(&dir);
|
||||
|
||||
if (!s_sd_ok)
|
||||
{
|
||||
s_sd_ok = true;
|
||||
ESP_LOGI(TAG, "sd card ready");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (s_mount_tried)
|
||||
{
|
||||
return; /* 只主动尝试一次 */
|
||||
}
|
||||
|
||||
s_mount_tried = true;
|
||||
|
||||
if (sd_spi_init() == ESP_OK)
|
||||
{
|
||||
s_sd_ok = true;
|
||||
ESP_LOGI(TAG, "sd card mounted by files app");
|
||||
}
|
||||
else
|
||||
{
|
||||
ESP_LOGW(TAG, "sd card not available");
|
||||
}
|
||||
}
|
||||
|
||||
/* ================================================================================================
|
||||
* 目录读取
|
||||
* ============================================================================================== */
|
||||
|
||||
/** qsort 比较:`..` → 目录 → 文件,同类按名称不区分大小写 */
|
||||
static int files_cmp(const void *a, const void *b)
|
||||
{
|
||||
const files_entry_t *x = (const files_entry_t *)a;
|
||||
const files_entry_t *y = (const files_entry_t *)b;
|
||||
|
||||
bool x_up = (strcmp(x->name, "..") == 0);
|
||||
bool y_up = (strcmp(y->name, "..") == 0);
|
||||
|
||||
if (x_up != y_up)
|
||||
{
|
||||
return x_up ? -1 : 1;
|
||||
}
|
||||
|
||||
if (x->is_dir != y->is_dir)
|
||||
{
|
||||
return x->is_dir ? -1 : 1;
|
||||
}
|
||||
|
||||
return strcasecmp(x->name, y->name);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 读取当前路径下的条目到 s_entries
|
||||
* @retval 有效条目数;s_load_res 给出 FatFs 的原始结果
|
||||
*/
|
||||
static int files_load(void)
|
||||
{
|
||||
FF_DIR dir;
|
||||
FILINFO *fi = NULL;
|
||||
FRESULT res = FR_OK;
|
||||
int n = 0;
|
||||
|
||||
/* 非根目录:第一行固定插入 "..",回车即返回上级 */
|
||||
if (strcmp(s_path, FILES_ROOT) != 0)
|
||||
{
|
||||
snprintf(s_entries[0].name, sizeof(s_entries[0].name), "..");
|
||||
s_entries[0].is_dir = true;
|
||||
s_entries[0].size = 0;
|
||||
n = 1;
|
||||
}
|
||||
|
||||
fi = (FILINFO *)malloc(sizeof(FILINFO));
|
||||
|
||||
if (fi == NULL)
|
||||
{
|
||||
ESP_LOGE(TAG, "no memory for FILINFO");
|
||||
s_load_res = FR_NOT_ENOUGH_CORE;
|
||||
return n;
|
||||
}
|
||||
|
||||
res = f_opendir(&dir, s_path);
|
||||
|
||||
if (res == FR_OK)
|
||||
{
|
||||
while (n < FILES_MAX_ENTRIES)
|
||||
{
|
||||
res = f_readdir(&dir, fi);
|
||||
|
||||
if (res != FR_OK)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (fi->fname[0] == '\0')
|
||||
{
|
||||
break; /* 目录遍历结束 */
|
||||
}
|
||||
|
||||
/* 隐藏 / 系统 / 卷标 一律不显示 */
|
||||
if ((fi->fattrib & AM_HID) || (fi->fattrib & AM_SYS) ||
|
||||
(fi->fattrib & FILES_AM_VOL))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (strcmp(fi->fname, ".") == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
/* strlcpy 不做格式化,可以避开 -Wformat-truncation;
|
||||
超过 FILES_NAME_MAX-1 的极端长名会被截断(仅此类目录无法再进入) */
|
||||
strlcpy(s_entries[n].name, fi->fname, sizeof(s_entries[n].name));
|
||||
s_entries[n].is_dir = (fi->fattrib & AM_DIR) != 0;
|
||||
s_entries[n].size = (uint32_t)fi->fsize;
|
||||
n++;
|
||||
}
|
||||
|
||||
f_closedir(&dir);
|
||||
}
|
||||
|
||||
free(fi);
|
||||
s_load_res = res;
|
||||
|
||||
if (n > 1)
|
||||
{
|
||||
qsort(s_entries, (size_t)n, sizeof(files_entry_t), files_cmp);
|
||||
}
|
||||
|
||||
return n;
|
||||
}
|
||||
|
||||
/* ================================================================================================
|
||||
* 界面刷新
|
||||
* ============================================================================================== */
|
||||
|
||||
/** 让选中项始终落在可见窗口内 */
|
||||
static void files_keep_visible(void)
|
||||
{
|
||||
if (s_sel < s_top)
|
||||
{
|
||||
s_top = s_sel;
|
||||
}
|
||||
else if (s_sel >= (s_top + FILES_ROWS))
|
||||
{
|
||||
s_top = s_sel - FILES_ROWS + 1;
|
||||
}
|
||||
|
||||
int max_top = (s_count > FILES_ROWS) ? (s_count - FILES_ROWS) : 0;
|
||||
|
||||
if (s_top > max_top)
|
||||
{
|
||||
s_top = max_top;
|
||||
}
|
||||
|
||||
if (s_top < 0)
|
||||
{
|
||||
s_top = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** K0 的含义随深度变化:根目录是“回主界面”,子目录是“返回上级” */
|
||||
static void files_update_keybar(void)
|
||||
{
|
||||
bool at_root = (strcmp(s_path, FILES_ROOT) == 0);
|
||||
|
||||
ui_key_bar_set_caption(&s_bar, APP_KEY_0, at_root ? "K0 HOME" : "K0 UP");
|
||||
}
|
||||
|
||||
static void files_hide_toast(void)
|
||||
{
|
||||
lv_obj_add_flag(s_toast, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
|
||||
static void files_show_toast(const char *text)
|
||||
{
|
||||
ui_label_set_if_changed(s_toast_lbl, text);
|
||||
lv_obj_clear_flag(s_toast, LV_OBJ_FLAG_HIDDEN);
|
||||
s_toast_tick = lv_tick_get();
|
||||
}
|
||||
|
||||
/** 把当前窗口内的条目画到固定的行控件上 */
|
||||
static void files_refresh(void)
|
||||
{
|
||||
char buf[80];
|
||||
|
||||
/* ---- 顶部:路径 + 条目数 ---- */
|
||||
ui_label_set_if_changed(s_lbl_path, s_path);
|
||||
|
||||
snprintf(buf, sizeof(buf), "%d", s_count);
|
||||
ui_label_set_if_changed(s_lbl_count, buf);
|
||||
|
||||
/* ---- 空目录 / 读取失败提示 ---- */
|
||||
if (s_count == 0)
|
||||
{
|
||||
if (s_load_res == FR_OK)
|
||||
{
|
||||
ui_label_set_if_changed(s_lbl_empty, "EMPTY FOLDER");
|
||||
}
|
||||
else if (s_sd_ok)
|
||||
{
|
||||
snprintf(buf, sizeof(buf), "CANNOT READ (%d)", (int)s_load_res);
|
||||
ui_label_set_if_changed(s_lbl_empty, buf);
|
||||
}
|
||||
else
|
||||
{
|
||||
ui_label_set_if_changed(s_lbl_empty, "NO SD CARD");
|
||||
}
|
||||
|
||||
lv_obj_clear_flag(s_lbl_empty, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
else
|
||||
{
|
||||
lv_obj_add_flag(s_lbl_empty, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
|
||||
/* ---- 列表行 ---- */
|
||||
for (int i = 0; i < FILES_ROWS; i++)
|
||||
{
|
||||
files_row_t *r = &s_rows[i];
|
||||
int idx = s_top + i;
|
||||
|
||||
if (idx >= s_count)
|
||||
{
|
||||
lv_obj_add_flag(r->row, LV_OBJ_FLAG_HIDDEN);
|
||||
continue;
|
||||
}
|
||||
|
||||
lv_obj_clear_flag(r->row, LV_OBJ_FLAG_HIDDEN);
|
||||
|
||||
const files_entry_t *e = &s_entries[idx];
|
||||
bool sel = (idx == s_sel);
|
||||
|
||||
/* 选中:深色底 + 强调色描边 */
|
||||
lv_obj_set_style_bg_color(r->row,
|
||||
lv_color_hex(sel ? UI_COL_CARD_SEL : UI_COL_CARD),
|
||||
LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(r->row, sel ? 1 : 0, LV_PART_MAIN);
|
||||
|
||||
/* 图标:目录黄色、音频青色、图片紫色、其它灰色 */
|
||||
const char *icon = LV_SYMBOL_FILE;
|
||||
uint32_t icon_col = UI_COL_DIM;
|
||||
|
||||
if (e->is_dir)
|
||||
{
|
||||
icon = LV_SYMBOL_DIRECTORY;
|
||||
icon_col = UI_COL_WARN;
|
||||
}
|
||||
else if (files_is_audio(e->name))
|
||||
{
|
||||
icon = LV_SYMBOL_AUDIO;
|
||||
icon_col = UI_COL_ACCENT;
|
||||
}
|
||||
else if (files_is_image(e->name))
|
||||
{
|
||||
icon = LV_SYMBOL_IMAGE;
|
||||
icon_col = UI_COL_ACCENT2;
|
||||
}
|
||||
|
||||
ui_label_set_if_changed(r->icon, icon);
|
||||
lv_obj_set_style_text_color(r->icon, lv_color_hex(icon_col), LV_PART_MAIN);
|
||||
|
||||
/* 名称(非 ASCII 折叠成 '?') */
|
||||
files_display_name(e->name, buf, sizeof(buf));
|
||||
ui_label_set_if_changed(r->name, buf);
|
||||
lv_obj_set_style_text_color(r->name,
|
||||
lv_color_hex(sel ? UI_COL_TEXT : 0xD5DBEA),
|
||||
LV_PART_MAIN);
|
||||
|
||||
/* 右侧:目录显示 <DIR>,文件显示大小 */
|
||||
if (e->is_dir)
|
||||
{
|
||||
ui_label_set_if_changed(r->size, "<DIR>");
|
||||
}
|
||||
else
|
||||
{
|
||||
files_format_size(e->size, buf, sizeof(buf));
|
||||
ui_label_set_if_changed(r->size, buf);
|
||||
}
|
||||
}
|
||||
|
||||
files_update_keybar();
|
||||
}
|
||||
|
||||
/* ================================================================================================
|
||||
* 导航
|
||||
* ============================================================================================== */
|
||||
|
||||
/**
|
||||
* @brief 打开一个目录(失败则退回原目录)
|
||||
*/
|
||||
static void files_open_dir(const char *path)
|
||||
{
|
||||
char target[FILES_PATH_MAX];
|
||||
char backup[FILES_PATH_MAX];
|
||||
int old_sel = s_sel;
|
||||
int n;
|
||||
|
||||
/* 先把目标路径复制出来,避免 path 指向 s_path 自身时 snprintf 重叠 */
|
||||
strlcpy(target, path, sizeof(target));
|
||||
snprintf(backup, sizeof(backup), "%s", s_path);
|
||||
strlcpy(s_path, target, sizeof(s_path));
|
||||
|
||||
files_ensure_mounted();
|
||||
|
||||
n = files_load();
|
||||
|
||||
if ((n == 0) && (s_load_res != FR_OK) && (strcmp(target, FILES_ROOT) != 0))
|
||||
{
|
||||
ESP_LOGW(TAG, "cannot open %s (%d)", target, (int)s_load_res);
|
||||
|
||||
strlcpy(s_path, backup, sizeof(s_path));
|
||||
n = files_load();
|
||||
s_sel = old_sel;
|
||||
}
|
||||
else
|
||||
{
|
||||
s_sel = 0;
|
||||
}
|
||||
|
||||
s_count = n;
|
||||
s_top = 0;
|
||||
|
||||
files_keep_visible();
|
||||
files_refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 返回上一级目录
|
||||
* @retval true 已返回;false 已在根目录
|
||||
*/
|
||||
static bool files_go_up(void)
|
||||
{
|
||||
if (strcmp(s_path, FILES_ROOT) == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
char parent[FILES_PATH_MAX];
|
||||
char *slash = strrchr(s_path, '/');
|
||||
|
||||
if (slash == NULL)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
size_t len = (size_t)(slash - s_path);
|
||||
|
||||
if (len == 0)
|
||||
{
|
||||
snprintf(parent, sizeof(parent), "%s", FILES_ROOT);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (len >= sizeof(parent))
|
||||
{
|
||||
len = sizeof(parent) - 1;
|
||||
}
|
||||
|
||||
memcpy(parent, s_path, len);
|
||||
parent[len] = '\0';
|
||||
}
|
||||
|
||||
files_open_dir(parent);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 打开选中项:目录则进入,文件则弹出详情
|
||||
*/
|
||||
static void files_enter(void)
|
||||
{
|
||||
if ((s_count <= 0) || (s_sel < 0) || (s_sel >= s_count))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const files_entry_t *e = &s_entries[s_sel];
|
||||
|
||||
if (e->is_dir)
|
||||
{
|
||||
if (strcmp(e->name, "..") == 0)
|
||||
{
|
||||
files_go_up();
|
||||
return;
|
||||
}
|
||||
|
||||
char next[FILES_PATH_MAX];
|
||||
size_t plen = strlen(s_path);
|
||||
size_t nlen = strlen(e->name);
|
||||
|
||||
/* 拼成 "<当前路径>/<子目录名>"(s_path 根目录时是 "0:",同样需要斜杠) */
|
||||
if ((plen + 1u + nlen + 1u) > sizeof(next))
|
||||
{
|
||||
files_show_toast("PATH TOO LONG");
|
||||
return;
|
||||
}
|
||||
|
||||
memcpy(next, s_path, plen);
|
||||
next[plen] = '/';
|
||||
memcpy(next + plen + 1u, e->name, nlen);
|
||||
next[plen + 1u + nlen] = '\0';
|
||||
|
||||
files_open_dir(next);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/* 普通文件:显示文件名 + 大小 + 类型 */
|
||||
char disp[80];
|
||||
char size_text[24];
|
||||
char text[128];
|
||||
|
||||
files_display_name(e->name, disp, sizeof(disp));
|
||||
files_format_size(e->size, size_text, sizeof(size_text));
|
||||
|
||||
snprintf(text, sizeof(text), "%s\n%s %s", disp, size_text,
|
||||
files_is_audio(e->name) ? "AUDIO" :
|
||||
(files_is_image(e->name) ? "IMAGE" : "FILE"));
|
||||
|
||||
files_show_toast(text);
|
||||
}
|
||||
|
||||
/* ================================================================================================
|
||||
* 对外接口
|
||||
* ============================================================================================== */
|
||||
|
||||
/** 创建一行(固定池,不随目录内容变化) */
|
||||
static void files_create_row(lv_obj_t *parent, int i)
|
||||
{
|
||||
files_row_t *r = &s_rows[i];
|
||||
|
||||
r->row = ui_card(parent, 0, (lv_coord_t)(i * FILES_ROW_H),
|
||||
UI_HOR_RES - 16, FILES_ROW_H - 2, UI_COL_CARD, 7);
|
||||
|
||||
lv_obj_set_style_pad_hor(r->row, 6, LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(r->row, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_border_color(r->row, lv_color_hex(UI_COL_ACCENT), LV_PART_MAIN);
|
||||
|
||||
r->icon = ui_label(r->row, LV_SYMBOL_FILE, &lv_font_montserrat_14, UI_COL_DIM);
|
||||
lv_obj_set_width(r->icon, 18);
|
||||
lv_obj_align(r->icon, LV_ALIGN_LEFT_MID, 0, 0);
|
||||
|
||||
r->size = ui_label(r->row, "", &lv_font_montserrat_12, UI_COL_SUB);
|
||||
lv_obj_set_width(r->size, ROW_SIZE_W);
|
||||
lv_obj_set_style_text_align(r->size, LV_TEXT_ALIGN_RIGHT, LV_PART_MAIN);
|
||||
lv_obj_align(r->size, LV_ALIGN_RIGHT_MID, 0, 0);
|
||||
|
||||
r->name = ui_label(r->row, "", &lv_font_montserrat_14, UI_COL_TEXT);
|
||||
lv_obj_set_size(r->name, ROW_NAME_W, FILES_ROW_H - 4);
|
||||
lv_label_set_long_mode(r->name, LV_LABEL_LONG_SCROLL_CIRCULAR);
|
||||
lv_obj_align(r->name, LV_ALIGN_LEFT_MID, ROW_NAME_X, 0);
|
||||
}
|
||||
|
||||
void ui_files_create(void)
|
||||
{
|
||||
s_scr = lv_obj_create(NULL);
|
||||
ui_style_screen(s_scr);
|
||||
|
||||
/* ---------------- 标题栏 ---------------- */
|
||||
lv_obj_t *header = ui_card(s_scr, 0, 0, UI_HOR_RES, FILES_HEADER_H, UI_COL_PANEL, 0);
|
||||
|
||||
lv_obj_t *title = ui_label(header, LV_SYMBOL_DIRECTORY " FILES",
|
||||
&lv_font_montserrat_16, 0x31C27C);
|
||||
lv_obj_align(title, LV_ALIGN_LEFT_MID, 10, 0);
|
||||
|
||||
lv_obj_t *hint = ui_label(header, "SD", &lv_font_montserrat_12, UI_COL_SUB);
|
||||
lv_obj_align(hint, LV_ALIGN_RIGHT_MID, -44, 0);
|
||||
|
||||
s_lbl_count = ui_label(header, "0", &lv_font_montserrat_12, UI_COL_ACCENT);
|
||||
lv_obj_align(s_lbl_count, LV_ALIGN_RIGHT_MID, -10, 0);
|
||||
|
||||
/* ---------------- 当前路径 ---------------- */
|
||||
lv_obj_t *path_bar = ui_card(s_scr, 8, FILES_PATH_Y, UI_HOR_RES - 16, FILES_PATH_H,
|
||||
UI_COL_TRACK, 6);
|
||||
|
||||
s_lbl_path = ui_label(path_bar, FILES_ROOT, &lv_font_montserrat_12, UI_COL_ACCENT);
|
||||
lv_obj_set_width(s_lbl_path, UI_HOR_RES - 16 - 12);
|
||||
lv_label_set_long_mode(s_lbl_path, LV_LABEL_LONG_SCROLL_CIRCULAR);
|
||||
lv_obj_align(s_lbl_path, LV_ALIGN_LEFT_MID, 6, 0);
|
||||
|
||||
/* ---------------- 列表容器(不支持滚轮,靠滑动窗口) ---------------- */
|
||||
lv_obj_t *list = lv_obj_create(s_scr);
|
||||
|
||||
lv_obj_set_pos(list, 8, FILES_LIST_Y);
|
||||
lv_obj_set_size(list, UI_HOR_RES - 16, FILES_LIST_H);
|
||||
lv_obj_clear_flag(list, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_set_style_bg_opa(list, LV_OPA_TRANSP, LV_PART_MAIN);
|
||||
lv_obj_set_style_border_width(list, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_pad_all(list, 0, LV_PART_MAIN);
|
||||
|
||||
for (int i = 0; i < FILES_ROWS; i++)
|
||||
{
|
||||
files_create_row(list, i);
|
||||
}
|
||||
|
||||
/* ---------------- 空目录 / 错误提示 ---------------- */
|
||||
s_lbl_empty = ui_label(s_scr, "NO SD CARD", &lv_font_montserrat_14, UI_COL_DIM);
|
||||
lv_obj_align(s_lbl_empty, LV_ALIGN_TOP_MID, 0, 116);
|
||||
|
||||
/* ---------------- 文件详情浮层(创建在最后,压在最上层) ---------------- */
|
||||
s_toast = ui_card(s_scr, 8, FILES_TOAST_Y, UI_HOR_RES - 16, 38, UI_COL_PANEL, 8);
|
||||
|
||||
lv_obj_set_style_border_width(s_toast, 1, LV_PART_MAIN);
|
||||
lv_obj_set_style_border_color(s_toast, lv_color_hex(UI_COL_ACCENT), LV_PART_MAIN);
|
||||
lv_obj_add_flag(s_toast, LV_OBJ_FLAG_HIDDEN);
|
||||
|
||||
s_toast_lbl = ui_label(s_toast, "", &lv_font_montserrat_12, UI_COL_TEXT);
|
||||
lv_obj_set_width(s_toast_lbl, UI_HOR_RES - 16 - 16);
|
||||
lv_obj_set_style_text_align(s_toast_lbl, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN);
|
||||
lv_obj_center(s_toast_lbl);
|
||||
|
||||
/* ---------------- 底部按键提示 ---------------- */
|
||||
ui_key_bar_create(s_scr, &s_bar, s_symbols, s_captions, s_enabled);
|
||||
}
|
||||
|
||||
void ui_files_show(void)
|
||||
{
|
||||
ui_key_bar_set_highlight(&s_bar, -1);
|
||||
files_hide_toast();
|
||||
|
||||
/* 每次进入应用都从根目录重新开始,行为可预测 */
|
||||
files_open_dir(FILES_ROOT);
|
||||
|
||||
lv_scr_load(s_scr);
|
||||
}
|
||||
|
||||
void ui_files_tick(void)
|
||||
{
|
||||
/* 文件详情浮层 2 秒后自动消失 */
|
||||
if (!lv_obj_has_flag(s_toast, LV_OBJ_FLAG_HIDDEN) &&
|
||||
(lv_tick_elaps(s_toast_tick) > 2000))
|
||||
{
|
||||
files_hide_toast();
|
||||
}
|
||||
|
||||
ui_key_bar_set_highlight(&s_bar, ui_flash_active(&s_flash));
|
||||
}
|
||||
|
||||
void ui_files_flash(int key)
|
||||
{
|
||||
ui_flash_trigger(&s_flash, key);
|
||||
}
|
||||
|
||||
void ui_files_handle_key(uint8_t key)
|
||||
{
|
||||
ui_flash_trigger(&s_flash, key);
|
||||
|
||||
switch (key)
|
||||
{
|
||||
case APP_KEY_1: /* 上一项(到顶后跳到底部) */
|
||||
if (s_count > 0)
|
||||
{
|
||||
s_sel = (s_sel <= 0) ? (s_count - 1) : (s_sel - 1);
|
||||
files_keep_visible();
|
||||
files_refresh();
|
||||
}
|
||||
break;
|
||||
|
||||
case APP_KEY_2: /* 下一项 */
|
||||
if (s_count > 0)
|
||||
{
|
||||
s_sel = (s_sel + 1) % s_count;
|
||||
files_keep_visible();
|
||||
files_refresh();
|
||||
}
|
||||
break;
|
||||
|
||||
case APP_KEY_3: /* 进入目录 / 查看文件 */
|
||||
files_enter();
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
bool ui_files_back(void)
|
||||
{
|
||||
if (strcmp(s_path, FILES_ROOT) == 0)
|
||||
{
|
||||
return false; /* 已在根目录:交给 ui.c 回主界面 */
|
||||
}
|
||||
|
||||
return files_go_up();
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
****************************************************************************************************
|
||||
* @file ui_files.h
|
||||
* @author HiFi Player Project
|
||||
* @version V1.0
|
||||
* @date 2026-01-01
|
||||
* @brief 文件管理器界面接口
|
||||
*
|
||||
* 按键:K0 返回上级目录(根目录时回主界面) / K1 上一项 / K2 下一项 / K3 打开
|
||||
****************************************************************************************************
|
||||
*/
|
||||
|
||||
#ifndef __UI_FILES_H
|
||||
#define __UI_FILES_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "lvgl.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/** 创建文件管理器界面(只创建,不显示) */
|
||||
void ui_files_create(void);
|
||||
|
||||
/** 显示界面:每次进入都从 SD 卡根目录开始浏览 */
|
||||
void ui_files_show(void);
|
||||
|
||||
/** 周期刷新(按键高亮) */
|
||||
void ui_files_tick(void);
|
||||
|
||||
/** 处理功能键(K1/K2/K3);K0 由 ui.c 统一处理 */
|
||||
void ui_files_handle_key(uint8_t key);
|
||||
|
||||
/** 仅触发按键高亮(不做动作) */
|
||||
void ui_files_flash(int key);
|
||||
|
||||
/**
|
||||
* @brief K0 短按时由 ui.c 先询问本界面能否“就地返回”
|
||||
* @retval true 已返回上一级目录(ui.c 不再回主界面)
|
||||
* false 已在根目录,交给 ui.c 回主界面
|
||||
*/
|
||||
bool ui_files_back(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* __UI_FILES_H */
|
||||
+266
-15
@@ -34,6 +34,11 @@
|
||||
#define WAV_MAX_BLOCKALIGN 8 /* 支持的 WAV 帧对齐上限(16/24/32bit × 最多 2 声道) */
|
||||
#define WAV_MAX_FRAMES 1024 /* WAV 原始 PCM 中转缓冲容纳的最大帧数 */
|
||||
|
||||
#define AUDIO_SRC_MAX_FRAMES 1024 /* 单次 audio_source_read 最多输出的帧数(与播放器缓冲一致) */
|
||||
#define DECIM_CHUNK_FRAMES 256 /* 抽取时每次处理多少输出帧(控制临时缓冲大小) */
|
||||
#define AUDIO_MAX_OUT_RATE 48000 /* 输出采样率上限 */
|
||||
#define AUDIO_MAX_DECIM 8 /* 最大抽取倍数 */
|
||||
|
||||
/* ---------------- 内部结构 ---------------- */
|
||||
struct audio_source {
|
||||
audio_codec_t codec;
|
||||
@@ -54,6 +59,10 @@ struct audio_source {
|
||||
|
||||
/* ---- FLAC ---- */
|
||||
flac_dec_t *flac;
|
||||
|
||||
/* ---- 高采样率抽取(192k/96k → ≤48k) ---- */
|
||||
uint8_t decim; /* 抽取倍数,1 表示不抽取 */
|
||||
uint8_t *decim_buf; /* 抽取用的原生采样中转缓冲 */
|
||||
};
|
||||
|
||||
/* ---------------- 位宽映射 ---------------- */
|
||||
@@ -76,6 +85,10 @@ static inline uint16_t pick_out_bits(uint16_t bits)
|
||||
return 32;
|
||||
}
|
||||
|
||||
/* 采样率限制 / 抽取(实现见文件末尾,供 audio_source_open 使用) */
|
||||
static bool decim_setup(audio_source_t *src);
|
||||
static void decim_mix(const uint8_t *src, uint8_t *dst, int frames, int decim, uint8_t ob);
|
||||
|
||||
/* ---------------- WAV 解析 ---------------- */
|
||||
|
||||
static inline uint16_t rd_u16(const uint8_t *p)
|
||||
@@ -256,8 +269,7 @@ static int wav_read(audio_source_t *src, void *out, int max_frames)
|
||||
}
|
||||
}
|
||||
|
||||
src->frames_read += frames;
|
||||
|
||||
/* 注意:frames_read 统一由 audio_source_read() 累加,这里不要重复计数 */
|
||||
return (int)frames;
|
||||
}
|
||||
|
||||
@@ -323,14 +335,25 @@ audio_source_t *audio_source_open(const char *path, audio_info_t *info)
|
||||
src->info.total_sec = (fi.total_samples > 0) ?
|
||||
(uint32_t)(fi.total_samples / fi.sample_rate) : 0;
|
||||
|
||||
/* 高采样率(96k/192k)抽取到 ES8388 / MCLK 能承受的范围 */
|
||||
if (!decim_setup(src))
|
||||
{
|
||||
ESP_LOGE(TAG, "unsupported sample rate %u Hz: %s",
|
||||
(unsigned)fi.sample_rate, path);
|
||||
audio_source_close(src);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (info != NULL)
|
||||
{
|
||||
*info = src->info;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "%s: FLAC %u Hz, %u bit -> I2S %u bit, %u ch, %u s",
|
||||
path, (unsigned)src->info.sample_rate, src->info.bits,
|
||||
src->info.out_bits, src->info.channels,
|
||||
ESP_LOGI(TAG, "%s: FLAC %u Hz%s, %u bit -> I2S %u bit @ %u Hz, %u ch, %u s",
|
||||
path, (unsigned)fi.sample_rate,
|
||||
(src->decim > 1) ? " (downsampled)" : "",
|
||||
src->info.bits, src->info.out_bits,
|
||||
(unsigned)src->info.sample_rate, src->info.channels,
|
||||
(unsigned)src->info.total_sec);
|
||||
|
||||
return src;
|
||||
@@ -359,6 +382,15 @@ audio_source_t *audio_source_open(const char *path, audio_info_t *info)
|
||||
src->info.out_bytes = (uint8_t)(bits / 8); /* WAV 位深必为 16/24/32 */
|
||||
src->info.total_sec = src->data_size / src->byte_rate;
|
||||
|
||||
/* 高采样率(96k/192k)抽取到 ES8388 / MCLK 能承受的范围 */
|
||||
if (!decim_setup(src))
|
||||
{
|
||||
ESP_LOGW(TAG, "unsupported sample rate %u Hz: %s",
|
||||
(unsigned)src->info.sample_rate, path);
|
||||
audio_source_close(src);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* 原始 PCM 中转缓冲(16/24/32bit × 最多 2 声道 → 每帧最多 8 字节) */
|
||||
src->scratch = (uint8_t *)malloc((size_t)WAV_MAX_FRAMES * src->block_align);
|
||||
|
||||
@@ -385,6 +417,87 @@ audio_source_t *audio_source_open(const char *path, audio_info_t *info)
|
||||
return src;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 带抽取的读取:把 decim 个原生帧合成 1 个输出帧
|
||||
*/
|
||||
static int audio_source_read_decim(audio_source_t *src, void *out, int max_frames)
|
||||
{
|
||||
uint8_t *dst = (uint8_t *)out;
|
||||
const uint8_t ob = src->info.out_bytes;
|
||||
const int decim = src->decim;
|
||||
int produced = 0;
|
||||
|
||||
while (produced < max_frames)
|
||||
{
|
||||
int room = max_frames - produced;
|
||||
|
||||
if (room > DECIM_CHUNK_FRAMES)
|
||||
{
|
||||
room = DECIM_CHUNK_FRAMES;
|
||||
}
|
||||
|
||||
if ((room * decim) > WAV_MAX_FRAMES)
|
||||
{
|
||||
room = WAV_MAX_FRAMES / decim; /* 受中转缓冲帧数限制 */
|
||||
}
|
||||
|
||||
if (room <= 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
/* 先凑满 room * decim 个原生帧,避免每次少读几帧造成丢样本 */
|
||||
const int need = room * decim;
|
||||
int have = 0;
|
||||
bool eof = false;
|
||||
|
||||
while (have < need)
|
||||
{
|
||||
int got = (src->codec == AUDIO_CODEC_FLAC)
|
||||
? flac_dec_read(src->flac,
|
||||
src->decim_buf + (size_t)have * 2u * ob,
|
||||
need - have)
|
||||
: wav_read(src,
|
||||
src->decim_buf + (size_t)have * 2u * ob,
|
||||
need - have);
|
||||
|
||||
if (got < 0)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (got == 0)
|
||||
{
|
||||
eof = true;
|
||||
break;
|
||||
}
|
||||
|
||||
have += got;
|
||||
}
|
||||
|
||||
int frames = have / decim;
|
||||
|
||||
if (frames > 0)
|
||||
{
|
||||
decim_mix(src->decim_buf, dst + (size_t)produced * 2u * ob,
|
||||
frames, decim, ob);
|
||||
produced += frames;
|
||||
}
|
||||
|
||||
if (eof)
|
||||
{
|
||||
break; /* 文件末尾 */
|
||||
}
|
||||
}
|
||||
|
||||
if (produced > 0)
|
||||
{
|
||||
src->frames_read += (uint64_t)produced;
|
||||
}
|
||||
|
||||
return produced;
|
||||
}
|
||||
|
||||
int audio_source_read(audio_source_t *src, void *out, int max_frames)
|
||||
{
|
||||
if ((src == NULL) || (out == NULL) || (max_frames <= 0))
|
||||
@@ -392,19 +505,26 @@ int audio_source_read(audio_source_t *src, void *out, int max_frames)
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (src->codec == AUDIO_CODEC_FLAC)
|
||||
if (max_frames > AUDIO_SRC_MAX_FRAMES)
|
||||
{
|
||||
int n = flac_dec_read(src->flac, out, max_frames);
|
||||
|
||||
if (n > 0)
|
||||
{
|
||||
src->frames_read += (uint64_t)n;
|
||||
}
|
||||
|
||||
return n;
|
||||
max_frames = AUDIO_SRC_MAX_FRAMES; /* 保护调用者缓冲 */
|
||||
}
|
||||
|
||||
return wav_read(src, out, max_frames);
|
||||
if (src->decim > 1)
|
||||
{
|
||||
return audio_source_read_decim(src, out, max_frames);
|
||||
}
|
||||
|
||||
int n = (src->codec == AUDIO_CODEC_FLAC)
|
||||
? flac_dec_read(src->flac, out, max_frames)
|
||||
: wav_read(src, out, max_frames);
|
||||
|
||||
if (n > 0)
|
||||
{
|
||||
src->frames_read += (uint64_t)n;
|
||||
}
|
||||
|
||||
return n;
|
||||
}
|
||||
|
||||
uint32_t audio_source_elapsed_sec(const audio_source_t *src)
|
||||
@@ -446,5 +566,136 @@ void audio_source_close(audio_source_t *src)
|
||||
free(src->scratch);
|
||||
src->scratch = NULL;
|
||||
|
||||
free(src->decim_buf);
|
||||
src->decim_buf = NULL;
|
||||
|
||||
free(src);
|
||||
}
|
||||
|
||||
/* ---------------- 采样率限制 ---------------- */
|
||||
|
||||
/**
|
||||
* @brief 计算抽取倍数(把采样率压到 AUDIO_MAX_OUT_RATE 以内)
|
||||
* @note 板载 ES8388 的 MCLK 由 I2S 产生,MCLK = mclk_multiple × fs.
|
||||
* 24bit 时 mclk_multiple 必须是 384(3 的倍数),若直接播放 192kHz,
|
||||
* MCLK 会高达 73.7MHz,远超 ES8388 的输入范围,结果是无声音或杂音.
|
||||
* 因此这里对 88.2k/96k/192k 等做整数抽取,统一落到 ≤48kHz.
|
||||
*/
|
||||
static uint8_t pick_decim(uint32_t rate)
|
||||
{
|
||||
uint8_t d = 1;
|
||||
|
||||
while (((rate / d) > AUDIO_MAX_OUT_RATE) && (d < AUDIO_MAX_DECIM))
|
||||
{
|
||||
d++;
|
||||
}
|
||||
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 按输出采样率为音源分配抽取缓冲
|
||||
* @retval true 成功;false 采样率过高或内存不足
|
||||
*/
|
||||
static bool decim_setup(audio_source_t *src)
|
||||
{
|
||||
uint8_t d = pick_decim(src->info.sample_rate);
|
||||
|
||||
if ((src->info.sample_rate / d) > AUDIO_MAX_OUT_RATE)
|
||||
{
|
||||
return false; /* 采样率过高,无法用整数抽取覆盖 */
|
||||
}
|
||||
|
||||
src->decim = d;
|
||||
|
||||
if (d <= 1)
|
||||
{
|
||||
return true; /* 常见 44.1k/48k:完全不额外占用内存 */
|
||||
}
|
||||
|
||||
size_t bytes = (size_t)DECIM_CHUNK_FRAMES * d * 2u * src->info.out_bytes;
|
||||
src->decim_buf = (uint8_t *)malloc(bytes);
|
||||
|
||||
if (src->decim_buf == NULL)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/* 对外统一暴露输出采样率,I2S / 界面都按这个值工作 */
|
||||
src->info.sample_rate /= d;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ---------------- 抽取用的采样读写 ---------------- */
|
||||
|
||||
static inline int32_t pcm_sample_get(const uint8_t *p, uint8_t bytes)
|
||||
{
|
||||
switch (bytes)
|
||||
{
|
||||
case 2:
|
||||
return (int16_t)(p[0] | (p[1] << 8));
|
||||
|
||||
case 3:
|
||||
{
|
||||
int32_t v = (int32_t)(p[0] | (p[1] << 8) | (p[2] << 16));
|
||||
|
||||
if (v & 0x800000)
|
||||
{
|
||||
v -= 0x1000000;
|
||||
}
|
||||
|
||||
return v;
|
||||
}
|
||||
|
||||
default:
|
||||
return (int32_t)((uint32_t)p[0] | ((uint32_t)p[1] << 8) |
|
||||
((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24));
|
||||
}
|
||||
}
|
||||
|
||||
static inline void pcm_sample_put(uint8_t *p, uint8_t bytes, int32_t v)
|
||||
{
|
||||
switch (bytes)
|
||||
{
|
||||
case 2:
|
||||
p[0] = (uint8_t)(v & 0xFF);
|
||||
p[1] = (uint8_t)((v >> 8) & 0xFF);
|
||||
break;
|
||||
|
||||
case 3:
|
||||
p[0] = (uint8_t)(v & 0xFF);
|
||||
p[1] = (uint8_t)((v >> 8) & 0xFF);
|
||||
p[2] = (uint8_t)((v >> 16) & 0xFF);
|
||||
break;
|
||||
|
||||
default:
|
||||
p[0] = (uint8_t)(v & 0xFF);
|
||||
p[1] = (uint8_t)((v >> 8) & 0xFF);
|
||||
p[2] = (uint8_t)((v >> 16) & 0xFF);
|
||||
p[3] = (uint8_t)((v >> 24) & 0xFF);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 整数抽取:把 decim 帧平均成 1 帧(简易低通,抑制抽取混叠)
|
||||
*/
|
||||
static void decim_mix(const uint8_t *src, uint8_t *dst, int frames, int decim, uint8_t ob)
|
||||
{
|
||||
for (int i = 0; i < frames; i++)
|
||||
{
|
||||
for (int c = 0; c < 2; c++)
|
||||
{
|
||||
int64_t sum = 0;
|
||||
|
||||
for (int k = 0; k < decim; k++)
|
||||
{
|
||||
const size_t idx = ((size_t)i * (size_t)decim + (size_t)k) * 2u + (size_t)c;
|
||||
sum += pcm_sample_get(src + idx * ob, ob);
|
||||
}
|
||||
|
||||
pcm_sample_put(dst + ((size_t)i * 2u + (size_t)c) * ob, ob, (int32_t)(sum / decim));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,12 @@
|
||||
* 17~24bit → 24bit(每采样 3 字节)
|
||||
* 25~32bit → 32bit(每采样 4 字节)
|
||||
* 非整字节位深(如 20bit)按左对齐放入更宽的槽位,音量不受影响。
|
||||
*
|
||||
* 采样率限制:
|
||||
* ES8388 的 MCLK 由 I2S 产生(MCLK = mclk_multiple × fs),24bit 时
|
||||
* mclk_multiple 必须是 384。若直接播放 192kHz,MCLK 会到 73.7MHz,
|
||||
* 远超 codec 能力。因此 88.2k/96k/192k 等会做整数抽取降到 ≤48kHz,
|
||||
* audio_info_t.sample_rate 返回的始终是「实际输出采样率」。
|
||||
****************************************************************************************************
|
||||
*/
|
||||
|
||||
@@ -43,7 +49,7 @@ typedef enum {
|
||||
/** 音源信息 */
|
||||
typedef struct {
|
||||
audio_codec_t codec; /* 容器类型 */
|
||||
uint32_t sample_rate; /* 采样率 → 用于配置 I2S 频率 */
|
||||
uint32_t sample_rate; /* 采样率 → 用于配置 I2S 频率(已按输出上限抽取) */
|
||||
uint16_t bits; /* 源位深(界面显示用) */
|
||||
uint16_t channels; /* 源声道数 */
|
||||
uint16_t out_bits; /* I2S 输出位宽:16 / 24 / 32 */
|
||||
|
||||
+15
-20
@@ -292,6 +292,8 @@ static void audio_set_format(uint32_t sample_rate, uint16_t bits)
|
||||
/* ---------------- 曲目扫描 ---------------- */
|
||||
/**
|
||||
* @brief 判断是否为可播放的音频文件(.wav / .flac)
|
||||
* @note 两种扩展名都要检查,不能因为不是 .wav 就提前 return false,
|
||||
* 否则 .flac 永远被过滤掉。
|
||||
*/
|
||||
static bool name_is_audio(const char *name)
|
||||
{
|
||||
@@ -302,34 +304,27 @@ static bool name_is_audio(const char *name)
|
||||
return false;
|
||||
}
|
||||
|
||||
const char *ext = name + len - 4; /* 指向 ".xxx" */
|
||||
const char *ext = name + len - 4; /* 指向最后 4 个字符 */
|
||||
|
||||
if (ext[0] != '.')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/* .wav */
|
||||
if (((ext[1] == 'w') || (ext[1] == 'W')) &&
|
||||
/* .wav —— 最后 4 个字符为 ".wav" */
|
||||
if ((ext[0] == '.') &&
|
||||
((ext[1] == 'w') || (ext[1] == 'W')) &&
|
||||
((ext[2] == 'a') || (ext[2] == 'A')) &&
|
||||
((ext[3] == 'v') || (ext[3] == 'V')))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/* .flac(长度至少 5:"a.flac") */
|
||||
if (len >= 5)
|
||||
{
|
||||
const char *f = name + len - 5;
|
||||
/* .flac —— 最后 5 个字符为 ".flac" */
|
||||
const char *ext5 = name + len - 5;
|
||||
|
||||
if ((f[0] == '.') &&
|
||||
((f[1] == 'f') || (f[1] == 'F')) &&
|
||||
((f[2] == 'l') || (f[2] == 'L')) &&
|
||||
((f[3] == 'a') || (f[3] == 'A')) &&
|
||||
((f[4] == 'c') || (f[4] == 'C')))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if ((ext5[0] == '.') &&
|
||||
((ext5[1] == 'f') || (ext5[1] == 'F')) &&
|
||||
((ext5[2] == 'l') || (ext5[2] == 'L')) &&
|
||||
((ext5[3] == 'a') || (ext5[3] == 'A')) &&
|
||||
((ext5[4] == 'c') || (ext5[4] == 'C')))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
+31
-6
@@ -25,6 +25,7 @@
|
||||
|
||||
#include "ui_home.h"
|
||||
#include "ui_player.h"
|
||||
#include "ui_files.h"
|
||||
|
||||
static app_mode_t s_last_mode = APP_MODE_NUM;
|
||||
|
||||
@@ -39,6 +40,10 @@ static void ui_load_mode(app_mode_t mode)
|
||||
ui_player_show();
|
||||
break;
|
||||
|
||||
case APP_MODE_FILES:
|
||||
ui_files_show();
|
||||
break;
|
||||
|
||||
case APP_MODE_HOME:
|
||||
default:
|
||||
ui_home_show();
|
||||
@@ -57,13 +62,20 @@ static void ui_dispatch(const app_event_t *ev)
|
||||
if (ev->key == APP_KEY_0)
|
||||
{
|
||||
/* 高亮交给当前界面,纯视觉效果 */
|
||||
if (mode == APP_MODE_PLAYER)
|
||||
switch (mode)
|
||||
{
|
||||
ui_player_flash(ev->key);
|
||||
}
|
||||
else
|
||||
{
|
||||
ui_home_flash(ev->key);
|
||||
case APP_MODE_PLAYER:
|
||||
ui_player_flash(ev->key);
|
||||
break;
|
||||
|
||||
case APP_MODE_FILES:
|
||||
ui_files_flash(ev->key);
|
||||
break;
|
||||
|
||||
case APP_MODE_HOME:
|
||||
default:
|
||||
ui_home_flash(ev->key);
|
||||
break;
|
||||
}
|
||||
|
||||
if (ev->type == APP_KEY_LONG)
|
||||
@@ -74,6 +86,10 @@ static void ui_dispatch(const app_event_t *ev)
|
||||
{
|
||||
app_switch_next(); /* 主界面短按:切换应用 */
|
||||
}
|
||||
else if ((mode == APP_MODE_FILES) && ui_files_back())
|
||||
{
|
||||
/* 文件管理器:先返回上一级目录,停留在本应用内 */
|
||||
}
|
||||
else
|
||||
{
|
||||
app_set_mode(APP_MODE_HOME); /* App 内短按:返回主界面 */
|
||||
@@ -94,6 +110,10 @@ static void ui_dispatch(const app_event_t *ev)
|
||||
ui_player_handle_key(ev->key);
|
||||
break;
|
||||
|
||||
case APP_MODE_FILES:
|
||||
ui_files_handle_key(ev->key);
|
||||
break;
|
||||
|
||||
case APP_MODE_HOME:
|
||||
default:
|
||||
ui_home_handle_key(ev->key);
|
||||
@@ -131,6 +151,10 @@ static void ui_tick_cb(lv_timer_t *timer)
|
||||
ui_player_tick();
|
||||
break;
|
||||
|
||||
case APP_MODE_FILES:
|
||||
ui_files_tick();
|
||||
break;
|
||||
|
||||
case APP_MODE_HOME:
|
||||
default:
|
||||
ui_home_tick();
|
||||
@@ -142,6 +166,7 @@ void ui_create(void)
|
||||
{
|
||||
ui_home_create();
|
||||
ui_player_create();
|
||||
ui_files_create();
|
||||
|
||||
s_last_mode = app_get_mode();
|
||||
ui_load_mode(s_last_mode);
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
*
|
||||
* 具体界面代码分别位于:
|
||||
* home/ 主界面(应用启动器)
|
||||
* files/ 文件管理器(浏览 SD 卡目录)
|
||||
* player/ 播放器(界面 + 播放核心)
|
||||
****************************************************************************************************
|
||||
*/
|
||||
|
||||
@@ -6,6 +6,7 @@ idf_component_register(
|
||||
"APP/ui_common.c"
|
||||
"APP/lvgl_port.c"
|
||||
"APP/home/ui_home.c"
|
||||
"APP/files/ui_files.c"
|
||||
"APP/player/ui_player.c"
|
||||
"APP/player/player.c"
|
||||
"APP/player/audio_source.c"
|
||||
@@ -14,6 +15,7 @@ idf_component_register(
|
||||
"."
|
||||
"APP"
|
||||
"APP/home"
|
||||
"APP/files"
|
||||
"APP/player"
|
||||
REQUIRES
|
||||
BSP
|
||||
|
||||
Reference in new issue
Block a user