HiFi player project, first commit.

This commit is contained in:
francis.wang committed 2026-10-26 20:07:12 +08:00
commit 4126ea13a1
49 files changed
+6333

No files matched your search

+330
View File
@@ -0,0 +1,330 @@
/**
****************************************************************************************************
* @file app.c
* @author HiFi Player Project
* @version V1.2
* @date 2026-01-01
* @brief 应用管理器 + 全局按键服务
*
* 模块职责:
* 1. 唯一持有 XL9555 按键的扫描权(其它模块不再直接读按键,避免重复扫描/漏键)。
* 2. 按键去抖 + 区分短按/长按,统一投递到事件队列(消费者是 ui.c)。
* 3. 维护当前应用模式、主界面应用表与选择项,并提供“返回 / 切换应用”接口。
****************************************************************************************************
*/
#include "app.h"
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "esp_log.h"
#include "lvgl.h"
#include "xl9555.h"
#define TAG "app"
/* ---------------- 按键服务参数 ---------------- */
#define KEY_SCAN_PERIOD_MS 10 /* 扫描周期 */
#define KEY_DEBOUNCE_COUNT 3 /* 连续 3 次一致才确认(约 30ms) */
#define KEY_LONG_PRESS_MS 700 /* 长按阈值 */
#define KEY_TASK_STACK (3 * 1024)
#define KEY_TASK_PRIO 5 /* 低于播放器(6),高于 LVGL(4) */
#define EVENT_QUEUE_LEN 16
/* ---------------- 主界面应用表(新增应用只需在这里加一条) ---------------- */
static const app_entry_t s_apps[] = {
{
.icon = LV_SYMBOL_AUDIO, /* 音符:音乐播放器 */
.title = "MUSIC",
.subtitle = "WAV files in SD /MUSIC",
.mode = APP_MODE_PLAYER,
.enabled = true,
.color_from = 0x8E5BFF,
.color_to = 0x3FB6F5,
},
{
.icon = LV_SYMBOL_DIRECTORY, /* 文件夹:SD 卡文件浏览 */
.title = "FILES",
.subtitle = "Browse files on SD card",
.mode = APP_MODE_NONE,
.enabled = false,
.color_from = 0x31C27C,
.color_to = 0x1E9C6B,
},
{
.icon = LV_SYMBOL_SETTINGS, /* 齿轮:设置 */
.title = "SETTINGS",
.subtitle = "Display, audio and keys",
.mode = APP_MODE_NONE,
.enabled = false,
.color_from = 0x9AA3B5,
.color_to = 0x6A7183,
},
{
.icon = LV_SYMBOL_LIST, /* 列表:关于 */
.title = "ABOUT",
.subtitle = "HiFi Player V1.2",
.mode = APP_MODE_NONE,
.enabled = false,
.color_from = 0xFFB020,
.color_to = 0xF07C18,
},
};
#define APP_COUNT ((int)(sizeof(s_apps) / sizeof(s_apps[0])))
/* ---------------- 内部状态 ---------------- */
typedef struct {
uint8_t last_raw; /* 上次原始电平(1=按下) */
uint8_t stable; /* 已确认的稳定状态 */
uint8_t counter; /* 连续一致计数 */
TickType_t press_tick; /* 按下时刻 */
bool long_sent; /* 本次按住是否已上报长按 */
} key_state_t;
static const uint16_t s_key_mask[APP_KEY_NUM] = { KEY0_IO, KEY1_IO, KEY2_IO, KEY3_IO };
static key_state_t s_keys[APP_KEY_NUM];
static QueueHandle_t s_event_queue = NULL;
static app_mode_t s_mode = APP_MODE_HOME;
static int s_selection = 0;
/* ---------------- 按键事件投递 ---------------- */
static void key_post(uint8_t key, uint8_t type)
{
app_event_t ev = { .key = key, .type = type };
if (s_event_queue != NULL)
{
/* 队列满时丢弃新事件,绝不阻塞按键任务 */
(void)xQueueSend(s_event_queue, &ev, 0);
}
}
/**
* @brief 按键扫描任务(非阻塞去抖 + 长短按识别)
*/
static void key_task(void *arg)
{
(void)arg;
memset(s_keys, 0, sizeof(s_keys));
while (1)
{
uint8_t data[2] = {0, 0};
if (xl9555_read_byte(data, 2) == ESP_OK)
{
uint16_t level = (uint16_t)(((uint16_t)data[1] << 8) | data[0]);
TickType_t now = xTaskGetTickCount();
for (int i = 0; i < APP_KEY_NUM; i++)
{
key_state_t *k = &s_keys[i];
uint8_t raw = ((level & s_key_mask[i]) == 0) ? 1 : 0; /* 低电平有效 */
/* --- 1. 去抖计数 --- */
if (raw != k->last_raw)
{
k->last_raw = raw;
k->counter = 0;
}
else if (k->counter < KEY_DEBOUNCE_COUNT)
{
k->counter++;
}
if (k->counter < KEY_DEBOUNCE_COUNT)
{
continue; /* 尚未稳定 */
}
/* --- 2. 状态变化 --- */
if (raw != k->stable)
{
k->stable = raw;
if (raw) /* 刚按下:记录时刻 */
{
k->press_tick = now;
k->long_sent = false;
}
else if (!k->long_sent) /* 松开且从未上报长按 → 短按 */
{
key_post((uint8_t)i, APP_KEY_SHORT);
}
}
/* --- 3. 按住不放 → 长按 --- */
else if (raw && !k->long_sent &&
((now - k->press_tick) >= pdMS_TO_TICKS(KEY_LONG_PRESS_MS)))
{
k->long_sent = true;
key_post((uint8_t)i, APP_KEY_LONG);
}
}
}
vTaskDelay(pdMS_TO_TICKS(KEY_SCAN_PERIOD_MS));
}
}
/* ---------------- 对外接口 ---------------- */
void app_start(void)
{
if (s_event_queue == NULL)
{
s_event_queue = xQueueCreate(EVENT_QUEUE_LEN, sizeof(app_event_t));
if (s_event_queue == NULL)
{
ESP_LOGE(TAG, "create event queue failed");
return;
}
}
s_mode = APP_MODE_HOME;
s_selection = 0;
if (xTaskCreate(key_task, "key_svc", KEY_TASK_STACK, NULL, KEY_TASK_PRIO, NULL) != pdPASS)
{
ESP_LOGE(TAG, "create key task failed");
}
else
{
ESP_LOGI(TAG, "app manager started, %d app(s) in launcher", APP_COUNT);
}
}
app_mode_t app_get_mode(void)
{
return s_mode;
}
void app_set_mode(app_mode_t mode)
{
if (mode == s_mode)
{
return;
}
s_mode = mode;
/* 丢弃切换过程中的残留事件,避免“上一个界面的按键”触发新界面的动作 */
app_event_flush();
ESP_LOGI(TAG, "switch to mode %d", (int)mode);
}
void app_back(void)
{
if (s_mode != APP_MODE_HOME)
{
app_set_mode(APP_MODE_HOME);
}
}
bool app_switch_next(void)
{
int count = 0;
const app_entry_t *apps = app_get_entries(&count);
if (count <= 0)
{
return false;
}
/* 找到当前模式的表项下标(找不到则视为 -1,从第 1 个开始找) */
int start = -1;
for (int i = 0; i < count; i++)
{
if (apps[i].mode == s_mode)
{
start = i;
break;
}
}
/* 向后找下一个“可用且已实现”的应用 */
for (int step = 1; step <= count; step++)
{
int idx = (start + step + count) % count;
if (apps[idx].enabled && (apps[idx].mode < APP_MODE_NUM))
{
s_selection = idx;
app_set_mode(apps[idx].mode);
return true;
}
}
return false;
}
const app_entry_t *app_get_entries(int *count)
{
if (count != NULL)
{
*count = APP_COUNT;
}
return s_apps;
}
int app_get_selection(void)
{
if ((s_selection < 0) || (s_selection >= APP_COUNT))
{
s_selection = 0;
}
return s_selection;
}
void app_select_move(int delta)
{
if (APP_COUNT <= 0)
{
return;
}
s_selection = (app_get_selection() + delta) % APP_COUNT;
if (s_selection < 0)
{
s_selection += APP_COUNT;
}
}
bool app_event_poll(app_event_t *ev)
{
if ((s_event_queue == NULL) || (ev == NULL))
{
return false;
}
return (xQueueReceive(s_event_queue, ev, 0) == pdTRUE);
}
bool app_event_wait(app_event_t *ev, uint32_t timeout_ms)
{
if ((s_event_queue == NULL) || (ev == NULL))
{
return false;
}
TickType_t ticks = (timeout_ms == 0) ? 0 : pdMS_TO_TICKS(timeout_ms);
return (xQueueReceive(s_event_queue, ev, ticks) == pdTRUE);
}
void app_event_flush(void)
{
if (s_event_queue != NULL)
{
(void)xQueueReset(s_event_queue);
}
}
+114
View File
@@ -0,0 +1,114 @@
/**
****************************************************************************************************
* @file app.h
* @author HiFi Player Project
* @version V1.2
* @date 2026-01-01
* @brief 应用管理器:主界面(应用启动器)/ 各 App 切换 + 全局按键服务
*
* 设计目的:
* 把“哪个界面在运行”和“按键归谁处理”从各 App 里抽出来。
* 新增应用(设置、相册、录音等)只需要:
* 1) 在 app_mode_t 里加一个模式;
* 2) 在 app.c 的 s_apps[] 表里加一条记录;
* 3) 新建自己的文件夹(如 APP/clock/),实现 ui_clock_create/show/tick/handle_key,
* 并在 ui.c 的路由表里挂上。
*
* 按键语义(由 ui.c 统一实现):
* K0 短按 = 返回主界面(主界面时为切换下一个应用)
* K0 长按 = 直接切换到下一个应用
* K1/K2/K3 = 由各 App 自己定义
****************************************************************************************************
*/
#ifndef __APP_H
#define __APP_H
#include <stdbool.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/* ---------------- 应用(界面)模式 ---------------- */
typedef enum {
APP_MODE_HOME = 0, /* 主界面:应用启动器 */
APP_MODE_PLAYER, /* 音乐播放器 */
APP_MODE_NUM, /* 模式总数(新增应用请加在这之前) */
} app_mode_t;
#define APP_MODE_NONE APP_MODE_NUM /* “无对应界面”(禁用的表项使用) */
/* ---------------- 4 个物理按键(XL9555 的 KEY0~KEY3) ---------------- */
typedef enum {
APP_KEY_0 = 0, /* KEY0 */
APP_KEY_1, /* KEY1 */
APP_KEY_2, /* KEY2 */
APP_KEY_3, /* KEY3 */
APP_KEY_NUM
} app_key_t;
/* ---------------- 按键事件类型 ---------------- */
typedef enum {
APP_KEY_SHORT = 0, /* 短按(松开时上报) */
APP_KEY_LONG, /* 长按(按住达到阈值立即上报,松开不再上报短按) */
} app_key_event_t;
typedef struct {
uint8_t key; /* app_key_t */
uint8_t type; /* app_key_event_t */
} app_event_t;
/* ---------------- 主界面应用表项 ---------------- */
typedef struct {
const char *icon; /* 图标字形(LV_SYMBOL_xxx) */
const char *title; /* 图标下方名称 */
const char *subtitle; /* 选中时显示的说明(可选,NULL 表示不显示) */
app_mode_t mode; /* 进入后切到的模式(APP_MODE_NONE 表示尚未实现) */
bool enabled; /* 是否可进入 */
uint32_t color_from; /* 图标渐变起始色(顶部) */
uint32_t color_to; /* 图标渐变结束色(底部) */
} app_entry_t;
/**
* @brief 初始化应用管理器(创建按键服务任务与事件队列)
* @note 必须在 xl9555_init() 之后调用
*/
void app_start(void);
/* ---------------- 模式 ---------------- */
app_mode_t app_get_mode(void);
/**
* @brief 切换应用模式
* @note 切换时会清空按键事件队列,避免上一次界面的按键“漏”到新界面
*/
void app_set_mode(app_mode_t mode);
/** 返回主界面(已在主界面时无动作) */
void app_back(void);
/**
* @brief 切换到“下一个可用的应用"
* @retval true 切换成功;false 没有其它可切换的应用
* @note 主界面 K0、以及在任意 App 内长按 K0 都会调用它
*/
bool app_switch_next(void);
/* ---------------- 主界面应用表 ---------------- */
const app_entry_t *app_get_entries(int *count);
int app_get_selection(void);
void app_select_move(int delta); /* 在当前应用表中循环移动选择项 */
/* ---------------- 按键事件 ---------------- */
bool app_event_poll(app_event_t *ev); /* 非阻塞 */
bool app_event_wait(app_event_t *ev, uint32_t timeout_ms); /* 阻塞等待 */
void app_event_flush(void); /* 清空队列 */
#ifdef __cplusplus
}
#endif
#endif /* __APP_H */
+349
View File
@@ -0,0 +1,349 @@
/**
****************************************************************************************************
* @file ui_home.c
* @author HiFi Player Project
* @version V1.3
* @date 2026-01-01
* @brief 主界面 —— iPhone 风格图标网格(应用启动器)
*
* ┌──────────────────────────────────┐
* │ APP LAUNCHER 1/4 │ 状态栏
* ├──────────────────────────────────┤
* │ ╭────╮ ╭────╮ ╭────╮ ╭────╮│
* │ │ ♪ │ │ 📁 │ │ ⚙ │ │ ☰ ││ 图标(圆角渐变 + 字形)
* │ ╰────╯ ╰────╯ ╰────╯ ╰────╯│
* │ MUSIC FILES SETTINGS ABOUT │ 名称
* │ WAV files in SD /MUSIC │ 选中项说明
* │ ● ○ ○ │ 页码圆点
* ├──────────────────────────────────┤
* │ [◀K1] [▶K2] [✔K3] [→K0] │ 按键提示(Dock)
* └──────────────────────────────────┘
*
* 按键:
* K1 上一个图标 K2 下一个图标 K3 打开 K0 切换应用(全局)
*
* 图标不使用图片文件,而是「圆角渐变底 + LVGL 内置字形」,因此无需额外资源,
* 新增应用时只要在 app.c 的表项里填 icon / color_from / color_to 即可。
****************************************************************************************************
*/
#include "ui_home.h"
#include <stdio.h>
#include "ui_common.h"
#include "app.h"
/* ---------------- 网格布局参数 ---------------- */
#define HOME_COLS 4 /* 每页图标列数(iPhone 风格一排) */
#define HOME_MAX_PAGES 4 /* 最多支持多少页 */
#define CELL_W 76
#define CELL_H 88
#define TILE_SIZE 68 /* 图标方块尺寸 */
#define TILE_RADIUS 16 /* 图标圆角(iOS 超级圆角观感) */
#define TILE_Y 0
#define NAME_DY 72 /* 名称相对单元格顶部的偏移 */
#define GRID_Y 36 /* 网格顶部 */
#define GRID_X 8 /* 网格左侧起始 x */
#define DOTS_Y 150 /* 页码圆点 y */
#define INFO_Y 132 /* 选中项说明 y */
/* ---------------- 控件 ---------------- */
static lv_obj_t *s_scr = NULL;
static lv_obj_t *s_lbl_index = NULL;
static lv_obj_t *s_lbl_info = NULL;
static lv_obj_t *s_dot[HOME_MAX_PAGES];
/* 每页的图标控件 */
static lv_obj_t *s_tile[HOME_COLS];
static lv_obj_t *s_glyph[HOME_COLS];
static lv_obj_t *s_name[HOME_COLS];
static ui_key_bar_t s_bar;
static ui_flash_t s_flash;
/* ---------------- 缓存(避免无谓重绘) ---------------- */
static int s_page = -1;
static int s_sel = -2;
/* ---------------- 按键提示(Dock) ---------------- */
static const char *const s_symbols[APP_KEY_NUM] = {
LV_SYMBOL_RIGHT, /* K0:切换应用 */
LV_SYMBOL_LEFT, /* K1:上一个图标 */
LV_SYMBOL_RIGHT, /* K2:下一个图标 */
LV_SYMBOL_OK, /* K3:打开 */
};
static const char *const s_captions[APP_KEY_NUM] = {
"K0 SWITCH",
"K1 PREV",
"K2 NEXT",
"K3 OPEN",
};
static const bool s_enabled[APP_KEY_NUM] = { true, true, true, true };
/* ---------------- 内部实现 ---------------- */
/**
* @brief 设置图标方块的外观(渐变 / 选中环 / 未实现时变暗)
*/
static void style_tile(int slot, const app_entry_t *app, bool selected)
{
lv_obj_t *tile = s_tile[slot];
lv_obj_set_style_bg_color(tile, lv_color_hex(app->color_from), LV_PART_MAIN);
lv_obj_set_style_bg_grad_color(tile, lv_color_hex(app->color_to), LV_PART_MAIN);
lv_obj_set_style_bg_grad_dir(tile, LV_GRAD_DIR_VER, LV_PART_MAIN);
lv_obj_set_style_bg_opa(tile, app->enabled ? LV_OPA_COVER : LV_OPA_50, LV_PART_MAIN);
/* 选中:白色描边环 + 更强的投影(iOS 图标的“抬起”观感) */
lv_obj_set_style_border_width(tile, selected ? 3 : 0, LV_PART_MAIN);
lv_obj_set_style_border_color(tile, lv_color_hex(UI_COL_TEXT), LV_PART_MAIN);
lv_obj_set_style_border_opa(tile, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_shadow_width(tile, selected ? 22 : 10, LV_PART_MAIN);
lv_obj_set_style_shadow_color(tile, lv_color_hex(app->color_to), LV_PART_MAIN);
lv_obj_set_style_shadow_opa(tile, selected ? LV_OPA_50 : LV_OPA_30, LV_PART_MAIN);
lv_obj_set_style_shadow_ofs_y(tile, selected ? 6 : 3, LV_PART_MAIN);
/* 字形 */
lv_obj_set_style_text_color(s_glyph[slot],
lv_color_hex(app->enabled ? 0xFFFFFF : UI_COL_DIM), LV_PART_MAIN);
/* 名称 */
lv_obj_set_style_text_color(s_name[slot],
lv_color_hex(app->enabled ? (selected ? UI_COL_TEXT : UI_COL_SUB)
: UI_COL_DIM),
LV_PART_MAIN);
}
/**
* @brief 刷新当前页的图标、页码与说明文字
*/
static void refresh(bool force)
{
int count = 0;
const app_entry_t *apps = app_get_entries(&count);
int sel = app_get_selection();
if (count <= 0)
{
sel = -1;
}
else if (sel >= count)
{
sel = count - 1;
}
int page = (sel < 0) ? 0 : (sel / HOME_COLS);
if (!force && (page == s_page) && (sel == s_sel))
{
return; /* 无变化 */
}
s_page = page;
s_sel = sel;
/* ---- 图标 ---- */
for (int i = 0; i < HOME_COLS; i++)
{
int idx = page * HOME_COLS + i;
if (idx < count)
{
const app_entry_t *app = &apps[idx];
ui_label_set_if_changed(s_glyph[i], app->icon);
ui_label_set_if_changed(s_name[i], app->title);
style_tile(i, app, (idx == sel));
lv_obj_clear_flag(s_tile[i], LV_OBJ_FLAG_HIDDEN);
lv_obj_clear_flag(s_name[i], LV_OBJ_FLAG_HIDDEN);
}
else
{
lv_obj_add_flag(s_tile[i], LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(s_name[i], LV_OBJ_FLAG_HIDDEN);
}
}
/* ---- 状态栏计数 ---- */
char buffer[24];
if ((count > 0) && (sel >= 0))
{
snprintf(buffer, sizeof(buffer), "%d/%d", sel + 1, count);
}
else
{
snprintf(buffer, sizeof(buffer), "--/--");
}
ui_label_set_if_changed(s_lbl_index, buffer);
/* ---- 选中项说明 ---- */
const char *info = "";
if ((sel >= 0) && (apps[sel].subtitle != NULL))
{
info = apps[sel].subtitle;
}
ui_label_set_if_changed(s_lbl_info, info);
/* ---- 页码圆点 ---- */
int pages = (count + HOME_COLS - 1) / HOME_COLS;
if (pages > HOME_MAX_PAGES)
{
pages = HOME_MAX_PAGES;
}
for (int i = 0; i < HOME_MAX_PAGES; i++)
{
if (i < pages)
{
lv_obj_clear_flag(s_dot[i], LV_OBJ_FLAG_HIDDEN);
lv_obj_set_style_bg_opa(s_dot[i], (i == page) ? LV_OPA_COVER : LV_OPA_30, LV_PART_MAIN);
}
else
{
lv_obj_add_flag(s_dot[i], LV_OBJ_FLAG_HIDDEN);
}
}
}
/**
* @brief 打开当前选中的应用
*/
static void open_selected(void)
{
int count = 0;
const app_entry_t *apps = app_get_entries(&count);
int sel = app_get_selection();
if ((sel < 0) || (sel >= count))
{
return;
}
if (!apps[sel].enabled || (apps[sel].mode >= APP_MODE_NUM))
{
return; /* 尚未实现的应用,忽略 */
}
app_set_mode(apps[sel].mode);
}
/* ---------------- 对外接口 ---------------- */
void ui_home_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, 28, UI_COL_PANEL, 0);
lv_obj_t *title = ui_label(header, LV_SYMBOL_LIST " APP LAUNCHER",
&lv_font_montserrat_14, UI_COL_SUB);
lv_obj_align(title, LV_ALIGN_LEFT_MID, 10, 0);
s_lbl_index = ui_label(header, "--/--", &lv_font_montserrat_12, UI_COL_SUB);
lv_obj_align(s_lbl_index, LV_ALIGN_RIGHT_MID, -10, 0);
/* ---------------- 图标网格(一排,iOS 风格) ---------------- */
for (int i = 0; i < HOME_COLS; i++)
{
lv_coord_t cell_x = (lv_coord_t)(GRID_X + i * CELL_W);
/* 图标方块(直接挂在屏幕上,避免投影被容器裁掉) */
s_tile[i] = ui_card(s_scr,
(lv_coord_t)(cell_x + (CELL_W - TILE_SIZE) / 2), GRID_Y,
TILE_SIZE, TILE_SIZE, UI_COL_CARD, TILE_RADIUS);
/* 图标字形 */
s_glyph[i] = ui_label(s_tile[i], "", &lv_font_montserrat_24, 0xFFFFFF);
lv_obj_center(s_glyph[i]);
/* 名称 */
s_name[i] = ui_label(s_scr, "", &lv_font_montserrat_12, UI_COL_SUB);
lv_obj_set_pos(s_name[i], cell_x, (lv_coord_t)(GRID_Y + NAME_DY));
lv_obj_set_size(s_name[i], CELL_W, 14);
lv_obj_set_style_text_align(s_name[i], LV_TEXT_ALIGN_CENTER, LV_PART_MAIN);
lv_label_set_long_mode(s_name[i], LV_LABEL_LONG_DOT);
}
/* ---------------- 选中项说明 ---------------- */
s_lbl_info = ui_label(s_scr, "", &lv_font_montserrat_12, UI_COL_DIM);
lv_obj_set_pos(s_lbl_info, 0, INFO_Y);
lv_obj_set_size(s_lbl_info, UI_HOR_RES, 14);
lv_obj_set_style_text_align(s_lbl_info, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN);
lv_label_set_long_mode(s_lbl_info, LV_LABEL_LONG_DOT);
/* ---------------- 页码圆点 ---------------- */
for (int i = 0; i < HOME_MAX_PAGES; i++)
{
lv_obj_t *dot = lv_obj_create(s_scr);
lv_obj_set_size(dot, 6, 6);
lv_obj_clear_flag(dot, LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_style_radius(dot, LV_RADIUS_CIRCLE, LV_PART_MAIN);
lv_obj_set_style_bg_color(dot, lv_color_hex(UI_COL_TEXT), LV_PART_MAIN);
lv_obj_set_style_bg_opa(dot, LV_OPA_30, LV_PART_MAIN);
lv_obj_set_style_border_width(dot, 0, LV_PART_MAIN);
int total_w = HOME_MAX_PAGES * 6 + (HOME_MAX_PAGES - 1) * 8;
lv_obj_set_pos(dot, (lv_coord_t)((UI_HOR_RES - total_w) / 2 + i * 14), DOTS_Y);
lv_obj_add_flag(dot, LV_OBJ_FLAG_HIDDEN);
s_dot[i] = dot;
}
/* ---------------- 底部按键提示(Dock) ---------------- */
ui_key_bar_create(s_scr, &s_bar, s_symbols, s_captions, s_enabled);
refresh(true);
}
void ui_home_show(void)
{
s_sel = -2; /* 强制刷新一次 */
s_page = -1;
refresh(true);
ui_key_bar_set_highlight(&s_bar, -1);
lv_scr_load(s_scr);
}
void ui_home_tick(void)
{
ui_key_bar_set_highlight(&s_bar, ui_flash_active(&s_flash));
refresh(false);
}
void ui_home_flash(int key)
{
ui_flash_trigger(&s_flash, key);
}
void ui_home_handle_key(uint8_t key)
{
ui_flash_trigger(&s_flash, key);
switch (key)
{
case APP_KEY_1: /* K1:上一个图标 */
app_select_move(-1);
break;
case APP_KEY_2: /* K2:下一个图标 */
app_select_move(+1);
break;
case APP_KEY_3: /* K3:打开选中的应用 */
open_selected();
break;
default:
break;
}
}
+41
View File
@@ -0,0 +1,41 @@
/**
****************************************************************************************************
* @file ui_home.h
* @author HiFi Player Project
* @version V1.2
* @date 2026-01-01
* @brief 主界面(iPhone 风格图标网格)界面接口
*
* 按键:K1 上一个图标 / K2 下一个图标 / K3 打开 / K0 切换应用(全局)
****************************************************************************************************
*/
#ifndef __UI_HOME_H
#define __UI_HOME_H
#include "lvgl.h"
#ifdef __cplusplus
extern "C" {
#endif
/** 创建主界面(只创建,不显示) */
void ui_home_create(void);
/** 显示主界面并复位内部状态 */
void ui_home_show(void);
/** 周期刷新显示 */
void ui_home_tick(void);
/** 处理本界面的功能键(K1/K2/K3);K0 由 ui.c 统一处理 */
void ui_home_handle_key(uint8_t key);
/** 仅触发按键高亮(不做动作),供 K0 使用 */
void ui_home_flash(int key);
#ifdef __cplusplus
}
#endif
#endif /* __UI_HOME_H */
+131
View File
@@ -0,0 +1,131 @@
/**
****************************************************************************************************
* @file lvgl_port.c
* @author HiFi Player Project
* @version V1.0
* @date 2026-01-01
* @brief LVGL 8.x 移植层
*
* 移植要点:
* 1. 绘图缓冲区必须放在内部 RAM,并带 DMA 能力(SPI DMA 不能直接读 PSRAM)。
* 2. flush_cb 中把 LVGL 的小端 RGB565 数据做字节交换,再交给 spilcd_flush()。
* 3. spilcd_flush() 是阻塞的(等 SPI 传输完成回调),因此这里调用后可以直接
* lv_disp_flush_ready(),实现简单可靠。
****************************************************************************************************
*/
#include "lvgl_port.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_heap_caps.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "lvgl.h"
#include "spilcd.h"
#define TAG "lvgl_port"
#define DISP_HOR_RES SPI_LCD_H_RES /* 320 */
#define DISP_VER_RES SPI_LCD_V_RES /* 240 */
#define DRAW_BUF_LINES 40 /* 单缓冲区高度(40 行约 25KB) */
#define LVGL_TASK_STACK (8 * 1024)
#define LVGL_TASK_PRIO 4
static lv_disp_draw_buf_t s_draw_buf;
static lv_disp_drv_t s_disp_drv;
static lv_color_t *s_draw_buf_mem = NULL;
static esp_timer_handle_t s_tick_timer = NULL;
/**
* @brief LVGL 刷新回调:把绘制好的区域送到 SPI LCD
*/
static void disp_flush_cb(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_p)
{
int32_t width = lv_area_get_width(area);
int32_t height = lv_area_get_height(area);
uint32_t pixels = (uint32_t)width * (uint32_t)height;
uint16_t *buf = (uint16_t *)color_p;
/* LVGL 内部是 RGB565 小端,ST7789 要求高字节在前,这里原地交换 */
for (uint32_t i = 0; i < pixels; i++)
{
buf[i] = (uint16_t)((buf[i] << 8) | (buf[i] >> 8));
}
/* x2 / y2 使用“不包含”的坐标 */
spilcd_flush((uint16_t)area->x1, (uint16_t)area->y1,
(uint16_t)(area->x2 + 1), (uint16_t)(area->y2 + 1), buf);
lv_disp_flush_ready(drv);
}
/**
* @brief LVGL 1ms 时基
*/
static void lv_tick_cb(void *arg)
{
(void)arg;
lv_tick_inc(1);
}
esp_err_t lvgl_port_init(void)
{
lv_init();
/* ---------------- 绘图缓冲区(内部 DMA RAM) ---------------- */
size_t pixel_count = (size_t)DISP_HOR_RES * DRAW_BUF_LINES;
s_draw_buf_mem = (lv_color_t *)heap_caps_malloc(pixel_count * sizeof(lv_color_t),
MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL);
if (s_draw_buf_mem == NULL)
{
ESP_LOGE(TAG, "no memory for LVGL draw buffer");
return ESP_ERR_NO_MEM;
}
lv_disp_draw_buf_init(&s_draw_buf, s_draw_buf_mem, NULL, pixel_count);
/* ---------------- 注册显示设备 ---------------- */
lv_disp_drv_init(&s_disp_drv);
s_disp_drv.hor_res = DISP_HOR_RES;
s_disp_drv.ver_res = DISP_VER_RES;
s_disp_drv.draw_buf = &s_draw_buf;
s_disp_drv.flush_cb = disp_flush_cb;
lv_disp_drv_register(&s_disp_drv);
/* ---------------- 1ms 时基 ---------------- */
const esp_timer_create_args_t timer_args = {
.callback = lv_tick_cb,
.name = "lv_tick",
};
ESP_ERROR_CHECK(esp_timer_create(&timer_args, &s_tick_timer));
ESP_ERROR_CHECK(esp_timer_start_periodic(s_tick_timer, 1000)); /* 1000us */
ESP_LOGI(TAG, "LVGL %d.%d.%d ready, draw buffer %u px",
LVGL_VERSION_MAJOR, LVGL_VERSION_MINOR, LVGL_VERSION_PATCH,
(unsigned)pixel_count);
return ESP_OK;
}
static void lvgl_task(void *arg)
{
(void)arg;
while (1)
{
lv_timer_handler();
vTaskDelay(pdMS_TO_TICKS(5));
}
}
void lvgl_port_start(void)
{
if (xTaskCreate(lvgl_task, "lvgl", LVGL_TASK_STACK, NULL, LVGL_TASK_PRIO, NULL) != pdPASS)
{
ESP_LOGE(TAG, "create lvgl task failed");
}
}
+35
View File
@@ -0,0 +1,35 @@
/**
****************************************************************************************************
* @file lvgl_port.h
* @author HiFi Player Project
* @version V1.0
* @date 2026-01-01
* @brief LVGL 8.x 移植层(显示 + 时基)
****************************************************************************************************
*/
#ifndef __LVGL_PORT_H
#define __LVGL_PORT_H
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
/**
* @brief 初始化 LVGL:显示驱动、绘图缓冲区、1ms 时基
* @note LVGL 会在内部使用 SPI LCD 的 panel 句柄,调用前必须先执行 spilcd_init()
*/
esp_err_t lvgl_port_init(void);
/**
* @brief 启动 LVGL 任务(lv_timer_handler 循环)
*/
void lvgl_port_start(void);
#ifdef __cplusplus
}
#endif
#endif /* __LVGL_PORT_H */
+450
View File
@@ -0,0 +1,450 @@
/**
****************************************************************************************************
* @file audio_source.c
* @author HiFi Player Project
* @version V1.5
* @date 2026-01-01
* @brief 统一音源实现(WAV 解析 + FLAC 解码转发)
*
* 两种格式都输出「立体声 + 源文件原生位深」的 PCM,不做降位:
*
* WAV :f_read 原始 PCM ──▶ 单声道则复制 ──▶ 输出
* FLAC :flac_dec_read() ──▶ 已在内部做声道规整 + 原生位深输出
*
* 格式识别:读取文件头 4 字节
* "RIFF" → WAV
* "fLaC" → 原生 FLAC
* "OggS" → Ogg FLAC
****************************************************************************************************
*/
#include "audio_source.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "esp_log.h"
#include "ff.h"
#include "flac_dec.h"
#define TAG "audio_src"
#define WAV_MAX_BLOCKALIGN 8 /* 支持的 WAV 帧对齐上限(16/24/32bit × 最多 2 声道) */
#define WAV_MAX_FRAMES 1024 /* WAV 原始 PCM 中转缓冲容纳的最大帧数 */
/* ---------------- 内部结构 ---------------- */
struct audio_source {
audio_codec_t codec;
audio_info_t info;
uint64_t frames_read; /* 已输出帧数(用于计算进度) */
/* ---- WAV ---- */
FIL wav_fp;
bool wav_open;
uint16_t block_align; /* 源每帧字节数 = channels * out_bytes */
uint32_t byte_rate;
uint32_t data_offset;
uint32_t data_size;
uint32_t data_read; /* 已读字节数 */
uint8_t *scratch; /* 原始 PCM 中转缓冲 */
int scratch_frames; /* 缓冲能容纳的帧数 */
/* ---- FLAC ---- */
flac_dec_t *flac;
};
/* ---------------- 位宽映射 ---------------- */
/**
* @brief 源位深 → I2S 输出位宽
*/
static inline uint16_t pick_out_bits(uint16_t bits)
{
if (bits <= 16)
{
return 16;
}
if (bits <= 24)
{
return 24;
}
return 32;
}
/* ---------------- WAV 解析 ---------------- */
static inline uint16_t rd_u16(const uint8_t *p)
{
return (uint16_t)(p[0] | (p[1] << 8));
}
static inline uint32_t rd_u32(const uint8_t *p)
{
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
}
/**
* @brief 解析 WAV 头部(支持 fmt/data 之间夹着 LIST、fact 等块)
* @note 调用前文件指针需在文件开头;结束后文件指针位于 data 起始处
*/
static bool wav_parse(FIL *fp, audio_info_t *info, uint32_t *data_offset,
uint32_t *data_size, uint16_t *block_align, uint32_t *byte_rate,
uint16_t *bits, uint16_t *channels)
{
uint8_t header[12];
uint8_t chunk[8];
uint8_t fmt[40];
UINT br = 0;
bool have_fmt = false;
bool have_data = false;
uint16_t audio_format = 0;
if ((f_read(fp, header, sizeof(header), &br) != FR_OK) || (br != sizeof(header)))
{
return false;
}
if ((memcmp(header, "RIFF", 4) != 0) || (memcmp(header + 8, "WAVE", 4) != 0))
{
return false;
}
while (!have_data)
{
if ((f_read(fp, chunk, sizeof(chunk), &br) != FR_OK) || (br != sizeof(chunk)))
{
break;
}
uint32_t size = rd_u32(chunk + 4);
if (memcmp(chunk, "fmt ", 4) == 0)
{
if ((size < 16) || (size > sizeof(fmt)))
{
break;
}
if ((f_read(fp, fmt, size, &br) != FR_OK) || (br != size))
{
break;
}
audio_format = rd_u16(fmt);
*channels = rd_u16(fmt + 2);
info->sample_rate = rd_u32(fmt + 4);
*byte_rate = rd_u32(fmt + 8);
*block_align = rd_u16(fmt + 12);
*bits = rd_u16(fmt + 14);
if ((audio_format == 0xFFFE) && (size >= 26))
{
/* WAVE_FORMAT_EXTENSIBLE:真实格式在 SubFormat GUID 的前两个字节 */
audio_format = rd_u16(fmt + 24);
}
have_fmt = true;
}
else if (memcmp(chunk, "data", 4) == 0)
{
FSIZE_t offset = f_tell(fp);
FSIZE_t remain = f_size(fp) - offset;
*data_offset = (uint32_t)offset;
/* 有些录音文件 data 长度写 0 或 0xFFFFFFFF,此时用文件剩余长度 */
*data_size = ((size == 0) || (size > (uint32_t)remain)) ? (uint32_t)remain : size;
have_data = true;
break;
}
else
{
/* 跳过不关心的块(块长度 2 字节对齐) */
if (f_lseek(fp, f_tell(fp) + size + (size & 0x01)) != FR_OK)
{
break;
}
}
}
if (!have_fmt || !have_data)
{
return false;
}
if (*block_align == 0)
{
*block_align = (uint16_t)(((*bits) / 8) * (*channels));
}
if (*byte_rate == 0)
{
*byte_rate = info->sample_rate * (*block_align);
}
/* 只接受线性 PCM,位深需为 16/24/32(非整字节位深无法从 WAV 头可靠判断) */
return (audio_format == 1) &&
(*channels >= 1) && (*channels <= 2) &&
(*block_align > 0) && (*block_align <= WAV_MAX_BLOCKALIGN) &&
((*bits == 16) || (*bits == 24) || (*bits == 32)) &&
(info->sample_rate >= 8000) && (*byte_rate > 0);
}
/**
* @brief WAV:把原始 PCM 规整成立体声(保留原生位深)
*/
static int wav_read(audio_source_t *src, void *out, int max_frames)
{
if ((src->data_read >= src->data_size) || (max_frames <= 0))
{
return 0;
}
uint32_t remain_frames = (src->data_size - src->data_read) / src->block_align;
uint32_t frames = (remain_frames < (uint32_t)max_frames) ? remain_frames : (uint32_t)max_frames;
if ((int)frames > src->scratch_frames)
{
frames = (uint32_t)src->scratch_frames;
}
if (frames == 0)
{
return 0;
}
UINT br = 0;
size_t want = (size_t)frames * src->block_align;
if (f_read(&src->wav_fp, src->scratch, (UINT)want, &br) != FR_OK)
{
return -1;
}
if (br < src->block_align)
{
return 0; /* 不足一帧,视作结束 */
}
frames = br / src->block_align;
src->data_read += br;
/* ---- 声道规整(位深保持不变) ---- */
const uint8_t *s = src->scratch;
uint8_t *dst = (uint8_t *)out;
const size_t ob = src->info.out_bytes;
if (src->info.channels == 2)
{
/* 最常见路径:立体声直接拷贝(block_align == 2 * out_bytes) */
memcpy(dst, s, (size_t)frames * 2 * ob);
}
else
{
/* 单声道:左右都写同一样本 */
for (uint32_t i = 0; i < frames; i++)
{
uint8_t *o = dst + i * 2 * ob;
memcpy(o, s + i * ob, ob);
memcpy(o + ob, s + i * ob, ob);
}
}
src->frames_read += frames;
return (int)frames;
}
/* ---------------- 对外接口 ---------------- */
audio_source_t *audio_source_open(const char *path, audio_info_t *info)
{
if (path == NULL)
{
return NULL;
}
audio_source_t *src = (audio_source_t *)calloc(1, sizeof(audio_source_t));
if (src == NULL)
{
return NULL;
}
/* ---------------- 1. 打开文件并嗅探文件头 ---------------- */
if (f_open(&src->wav_fp, path, FA_READ) != FR_OK)
{
ESP_LOGE(TAG, "open %s failed", path);
audio_source_close(src);
return NULL;
}
src->wav_open = true;
uint8_t magic[4] = {0};
UINT br = 0;
bool is_flac = false;
if ((f_read(&src->wav_fp, magic, sizeof(magic), &br) == FR_OK) && (br == sizeof(magic)))
{
is_flac = (memcmp(magic, "fLaC", 4) == 0) || (memcmp(magic, "OggS", 4) == 0);
}
/* ---------------- 2A. FLAC ---------------- */
if (is_flac)
{
/* 交给专门的解码器重新打开(它需要从文件开头开始解析) */
f_close(&src->wav_fp);
src->wav_open = false;
flac_info_t fi;
src->flac = flac_dec_open(path);
if ((src->flac == NULL) || (flac_dec_get_info(src->flac, &fi) != 0))
{
ESP_LOGE(TAG, "flac open failed: %s", path);
audio_source_close(src);
return NULL;
}
src->codec = AUDIO_CODEC_FLAC;
src->info.codec = AUDIO_CODEC_FLAC;
src->info.sample_rate = fi.sample_rate;
src->info.bits = fi.bits;
src->info.channels = fi.channels;
src->info.out_bytes = fi.out_bytes;
src->info.out_bits = (uint16_t)fi.out_bytes * 8;
src->info.total_sec = (fi.total_samples > 0) ?
(uint32_t)(fi.total_samples / fi.sample_rate) : 0;
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,
(unsigned)src->info.total_sec);
return src;
}
/* ---------------- 2B. WAV ---------------- */
src->codec = AUDIO_CODEC_WAV;
src->info.codec = AUDIO_CODEC_WAV;
f_lseek(&src->wav_fp, 0); /* 回到文件开头重新解析 */
uint16_t bits = 0;
uint16_t channels = 0;
if (!wav_parse(&src->wav_fp, &src->info, &src->data_offset, &src->data_size,
&src->block_align, &src->byte_rate, &bits, &channels))
{
ESP_LOGW(TAG, "unsupported wav: %s", path);
audio_source_close(src);
return NULL;
}
src->info.bits = bits;
src->info.channels = channels;
src->info.out_bits = pick_out_bits(bits);
src->info.out_bytes = (uint8_t)(bits / 8); /* WAV 位深必为 16/24/32 */
src->info.total_sec = src->data_size / src->byte_rate;
/* 原始 PCM 中转缓冲(16/24/32bit × 最多 2 声道 → 每帧最多 8 字节) */
src->scratch = (uint8_t *)malloc((size_t)WAV_MAX_FRAMES * src->block_align);
if (src->scratch == NULL)
{
ESP_LOGE(TAG, "no memory for wav scratch");
audio_source_close(src);
return NULL;
}
src->scratch_frames = WAV_MAX_FRAMES;
f_lseek(&src->wav_fp, src->data_offset);
if (info != NULL)
{
*info = src->info;
}
ESP_LOGI(TAG, "%s: WAV %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,
(unsigned)src->info.total_sec);
return src;
}
int audio_source_read(audio_source_t *src, void *out, int max_frames)
{
if ((src == NULL) || (out == NULL) || (max_frames <= 0))
{
return -1;
}
if (src->codec == AUDIO_CODEC_FLAC)
{
int n = flac_dec_read(src->flac, out, max_frames);
if (n > 0)
{
src->frames_read += (uint64_t)n;
}
return n;
}
return wav_read(src, out, max_frames);
}
uint32_t audio_source_elapsed_sec(const audio_source_t *src)
{
if ((src == NULL) || (src->info.sample_rate == 0))
{
return 0;
}
uint32_t sec = (uint32_t)(src->frames_read / src->info.sample_rate);
if ((src->info.total_sec > 0) && (sec > src->info.total_sec))
{
sec = src->info.total_sec;
}
return sec;
}
void audio_source_close(audio_source_t *src)
{
if (src == NULL)
{
return;
}
if (src->flac != NULL)
{
flac_dec_close(src->flac);
src->flac = NULL;
}
if (src->wav_open)
{
f_close(&src->wav_fp);
src->wav_open = false;
}
free(src->scratch);
src->scratch = NULL;
free(src);
}
+86
View File
@@ -0,0 +1,86 @@
/**
****************************************************************************************************
* @file audio_source.h
* @author HiFi Player Project
* @version V1.5
* @date 2026-01-01
* @brief 统一音源接口(WAV / FLAC)
*
* 上层播放器只关心“给我一块立体声 PCM 和它对应的位宽”,不必区分容器格式:
*
* audio_source_open() ── 按文件头自动识别 RIFF(WAV) / fLaC、OggS(FLAC)
* │ 同时给出该曲目应使用的 I2S 位宽 out_bits(16/24/32)
* audio_source_read() ── 输出 **源文件原生位深** 的立体声交错 PCM
* │ 单声道自动复制;>2 声道只取前两声道;不做降位
* audio_source_close()
*
* 时间进度统一用“已播放帧数 / 采样率”计算,对两种格式都准确。
*
* 位宽映射(源位深 → I2S 位宽):
* ≤16bit → 16bit(每采样 2 字节)
* 17~24bit → 24bit(每采样 3 字节)
* 25~32bit → 32bit(每采样 4 字节)
* 非整字节位深(如 20bit)按左对齐放入更宽的槽位,音量不受影响。
****************************************************************************************************
*/
#ifndef __AUDIO_SOURCE_H
#define __AUDIO_SOURCE_H
#include <stdbool.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/** 支持的音频容器 */
typedef enum {
AUDIO_CODEC_WAV = 0, /* RIFF/WAVE,PCM 16/24/32bit */
AUDIO_CODEC_FLAC, /* 原生 FLAC 或 Ogg FLAC */
} audio_codec_t;
/** 音源信息 */
typedef struct {
audio_codec_t codec; /* 容器类型 */
uint32_t sample_rate; /* 采样率 → 用于配置 I2S 频率 */
uint16_t bits; /* 源位深(界面显示用) */
uint16_t channels; /* 源声道数 */
uint16_t out_bits; /* I2S 输出位宽:16 / 24 / 32 */
uint8_t out_bytes; /* 输出每采样字节数:2 / 3 / 4 */
uint32_t total_sec; /* 总时长(秒),0 表示未知 */
} audio_info_t;
typedef struct audio_source audio_source_t;
/**
* @brief 打开音频文件并解析头部
* @param path 文件路径
* @param info 输出音源信息(可为 NULL)
* @retval 句柄;失败返回 NULL
*/
audio_source_t *audio_source_open(const char *path, audio_info_t *info);
/**
* @brief 读取一块 PCM
* @param src 音源句柄
* @param out 输出缓冲区(立体声交错,位宽 = info.out_bits)
* @param max_frames 最多读取多少帧(1 帧 = 左右各 1 个采样)
* @retval >0 实际帧数
* 0 播放结束
* <0 解码/读取错误
* @note 每帧占用 2 * out_bytes 字节,调用者需保证缓冲区足够
*/
int audio_source_read(audio_source_t *src, void *out, int max_frames);
/** @brief 已播放的秒数(按解码帧数计算) */
uint32_t audio_source_elapsed_sec(const audio_source_t *src);
/** @brief 关闭音源并释放资源(可传 NULL) */
void audio_source_close(audio_source_t *src);
#ifdef __cplusplus
}
#endif
#endif /* __AUDIO_SOURCE_H */
+578
View File
@@ -0,0 +1,578 @@
/**
****************************************************************************************************
* @file flac_dec.cpp
* @author HiFi Player Project
* @version V1.5
* @date 2026-01-01
* @brief FLAC 解码器的 C++ 实现(对外暴露纯 C 接口,见 flac_dec.h)
*
* 数据流:
* FATFS 文件 ──f_read──▶ 8KB 码流缓冲 ──decode()──▶ 原生位深交错 PCM
* │
* 单声道复制 / 多声道取前两声道
* ▼
* 立体声 + 原生位深(左对齐)→ I2S
*
* 位的处理:
* 库的 uint8_t 输出重载是“原生字节打包 + 左对齐”:
* 16bit → 2 字节、24bit → 3 字节、32bit → 4 字节,
* 20bit 这类非整字节位深则占 3 字节并填满其高 20 位。
* 正好等价于 I2S 的 16/24/32bit 数据格式,所以这里不做任何降位,
* 上层按 out_bytes 配置 I2S 位宽即可获得无损通路。
*
* 码流缓冲策略:未消费数据前移 + 追加读取,保证 NEED_MORE_DATA 时不丢数据,
* 并带“无进展”保护,坏文件不会死循环。
****************************************************************************************************
*/
#include "flac_dec.h"
#include <cstdlib>
#include <cstring>
#include <new>
#include "esp_heap_caps.h"
#include "esp_log.h"
#include "micro_flac/flac_decoder.h"
#include "ff.h"
#define TAG "flac_dec"
#define IN_BUF_SIZE (8 * 1024) /* 码流缓冲(压缩数据) */
#define MAX_STALL 8 /* 连续无进展的容忍次数 */
struct flac_dec {
FIL fp; /* FATFS 文件对象 */
bool fp_open;
micro_flac::FLACDecoder *decoder;
uint8_t *in_buf; /* 压缩码流缓冲 */
size_t in_len; /* 缓冲内有效字节数 */
size_t in_pos; /* 已消费位置 */
bool in_eof; /* 文件已读完 */
uint8_t *pcm; /* 解码输出(源声道数 × 原生位深,交错) */
size_t pcm_capacity; /* 缓冲字节数 */
size_t pcm_bytes; /* 本帧有效字节数 */
size_t pcm_pos; /* 已消费字节位置 */
uint8_t bps; /* 源每采样字节数 */
uint8_t out_bytes; /* 输出每采样字节数(2/3/4) */
flac_info_t info;
bool header_ready;
bool stream_end;
bool failed;
};
/* ================================================================================================
* 内部工具
* ============================================================================================== */
/**
* @brief 按 STREAMINFO 的块大小分配解码输出缓冲(字节数)
* @retval true 成功
*/
static bool ensure_pcm_buffer(flac_dec_t *d, size_t bytes)
{
if (bytes <= d->pcm_capacity)
{
return true;
}
/* 优先放 PSRAM,失败再退回内部 RAM */
void *p = heap_caps_malloc(bytes, MALLOC_CAP_SPIRAM);
if (p == NULL)
{
ESP_LOGW(TAG, "psram alloc failed (%u bytes), try internal", (unsigned)bytes);
p = malloc(bytes);
}
if (p == NULL)
{
ESP_LOGE(TAG, "no memory for pcm buffer (%u bytes)", (unsigned)bytes);
return false;
}
free(d->pcm); /* heap_caps_malloc 的指针可以用 free 释放 */
d->pcm = (uint8_t *)p;
d->pcm_capacity = bytes;
return true;
}
/**
* @brief 把未消费的码流前移,并从文件追加新数据
* @retval true 操作成功(不代表一定有新数据;看 in_len 是否增长)
* false 读文件失败
*/
static bool feed_more(flac_dec_t *d)
{
size_t remain = d->in_len - d->in_pos;
if ((d->in_pos > 0) && (remain > 0))
{
memmove(d->in_buf, d->in_buf + d->in_pos, remain);
}
d->in_len = remain;
d->in_pos = 0;
if (d->in_eof || (d->in_len >= IN_BUF_SIZE))
{
return true; /* 文件读完或缓冲已满,只能靠现有数据 */
}
UINT br = 0;
FRESULT fr = f_read(&d->fp, d->in_buf + d->in_len, (UINT)(IN_BUF_SIZE - d->in_len), &br);
if (fr != FR_OK)
{
ESP_LOGE(TAG, "f_read failed (%d)", (int)fr);
return false;
}
d->in_len += br;
if (br == 0)
{
d->in_eof = true;
}
return true;
}
/** @brief 是否还有可交给解码器的数据 */
static inline bool has_input(const flac_dec_t *d)
{
return d->in_pos < d->in_len;
}
/**
* @brief 按源位深推导输出每采样字节数
* @note 1 字节(8bit 及以下)统一扩展成 2 字节(左对齐),
* 其余直接沿用源字节数(2/3/4),这样 I2S 用 16/24/32bit 都能直接接收
*/
static inline uint8_t pick_out_bytes(uint8_t bps)
{
if (bps < 2)
{
return 2;
}
if (bps > 4)
{
return 4;
}
return bps;
}
/**
* @brief 写一个输出采样(把源采样按需要补齐/复制)
* @param dst 输出位置(out_bytes 字节,小端,左对齐)
* @param src 源采样起点(bps 字节,小端,左对齐)
*/
static inline void write_sample(uint8_t *dst, const uint8_t *src, uint8_t bps, uint8_t out_bytes)
{
if (bps == out_bytes)
{
memcpy(dst, src, out_bytes);
return;
}
if (bps == 1)
{
/* 8bit 及以下:左对齐扩成 16bit(低字节补 0,高字节就是原样本) */
dst[0] = 0;
dst[1] = src[0];
for (uint8_t i = 2; i < out_bytes; i++)
{
dst[i] = (src[0] & 0x80) ? 0xFF : 0x00; /* 符号扩展到更高的字节 */
}
return;
}
/* 理论上不会走到这里;保守处理为左对齐拷贝 */
uint8_t n = (bps < out_bytes) ? bps : out_bytes;
memset(dst, 0, out_bytes);
memcpy(dst + (out_bytes - n), src, n);
}
/**
* @brief 把解码结果(源声道数)规整成 2 声道,写入调用者缓冲
* @param frames 本次要输出的帧数
* @retval 无
*/
static void expand_frames(flac_dec_t *d, const uint8_t *src, uint8_t *dst, size_t frames)
{
const size_t ch = d->info.channels;
const size_t bps = d->bps;
const size_t ob = d->out_bytes;
if ((ch == 2) && (bps == ob))
{
memcpy(dst, src, frames * 2 * ob); /* 最常见路径:立体声原生位深 */
return;
}
for (size_t i = 0; i < frames; i++)
{
const uint8_t *s = src + i * ch * bps;
uint8_t *o = dst + i * 2 * ob;
write_sample(o, s, (uint8_t)bps, (uint8_t)ob); /* 左声道 = 声道 0 */
if (ch >= 2)
{
write_sample(o + ob, s + bps, (uint8_t)bps, (uint8_t)ob); /* 右声道 = 声道 1 */
}
else
{
memcpy(o + ob, o, ob); /* 单声道复制 */
}
}
}
/* ================================================================================================
* 对外接口
* ============================================================================================== */
flac_dec_t *flac_dec_open(const char *path)
{
if (path == NULL)
{
return NULL;
}
flac_dec_t *d = new (std::nothrow) flac_dec();
if (d == NULL)
{
ESP_LOGE(TAG, "no memory for decoder");
return NULL;
}
memset(d, 0, sizeof(*d));
if (f_open(&d->fp, path, FA_READ) != FR_OK)
{
ESP_LOGE(TAG, "open %s failed", path);
flac_dec_close(d);
return NULL;
}
d->fp_open = true;
d->decoder = new (std::nothrow) micro_flac::FLACDecoder();
if (d->decoder == NULL)
{
ESP_LOGE(TAG, "no memory for FLACDecoder");
flac_dec_close(d);
return NULL;
}
d->in_buf = (uint8_t *)malloc(IN_BUF_SIZE);
if (d->in_buf == NULL)
{
ESP_LOGE(TAG, "no memory for input buffer");
flac_dec_close(d);
return NULL;
}
/* ---------------- 解析头部(STREAMINFO) ---------------- */
int stall = 0;
while (!d->header_ready)
{
if (!has_input(d) && !feed_more(d))
{
flac_dec_close(d);
return NULL;
}
if (!has_input(d))
{
ESP_LOGE(TAG, "empty or truncated stream");
flac_dec_close(d);
return NULL;
}
size_t consumed = 0;
size_t samples = 0;
/* 头部解析阶段不需要输出缓冲 */
micro_flac::FLACDecoderResult res =
d->decoder->decode(d->in_buf + d->in_pos, d->in_len - d->in_pos,
(uint8_t *)NULL, 0, consumed, samples);
d->in_pos += consumed;
if (res == micro_flac::FLAC_DECODER_HEADER_READY)
{
const micro_flac::FLACStreamInfo &si = d->decoder->get_stream_info();
if (!si.is_valid() || (si.num_channels() < 1) || (si.num_channels() > 8) ||
(si.max_block_size() == 0) || (si.bytes_per_sample() == 0) ||
(si.bytes_per_sample() > 4))
{
ESP_LOGE(TAG, "invalid stream info");
flac_dec_close(d);
return NULL;
}
d->info.sample_rate = si.sample_rate();
d->info.channels = (uint8_t)si.num_channels();
d->info.bits = (uint8_t)si.bits_per_sample();
d->info.max_block_size = si.max_block_size();
d->info.total_samples = si.total_samples_per_channel();
d->bps = (uint8_t)si.bytes_per_sample();
d->out_bytes = pick_out_bytes(d->bps);
d->info.out_bytes = d->out_bytes;
/* 解码输出缓冲按「最大块 × 声道数 × 源字节数」分配 */
if (!ensure_pcm_buffer(d, (size_t)d->info.max_block_size *
d->info.channels * d->bps))
{
flac_dec_close(d);
return NULL;
}
d->header_ready = true;
stall = 0;
}
else if (res == micro_flac::FLAC_DECODER_NEED_MORE_DATA)
{
size_t before = d->in_len - d->in_pos; /* feed 之前的待解析字节数 */
if (!feed_more(d))
{
flac_dec_close(d);
return NULL;
}
/* 没有新数据可加(文件读完 / 缓冲已满)→ 头部不完整 */
if ((d->in_len <= before) && d->in_eof)
{
ESP_LOGE(TAG, "truncated header");
flac_dec_close(d);
return NULL;
}
if (consumed == 0)
{
if (++stall > MAX_STALL)
{
ESP_LOGE(TAG, "header parse stalled");
flac_dec_close(d);
return NULL;
}
}
else
{
stall = 0;
}
}
else if (res == micro_flac::FLAC_DECODER_END_OF_STREAM)
{
ESP_LOGE(TAG, "no audio in stream");
flac_dec_close(d);
return NULL;
}
else
{
ESP_LOGE(TAG, "header parse failed (%d)", (int)res);
flac_dec_close(d);
return NULL;
}
}
ESP_LOGI(TAG, "%s: %u Hz, %u bit (out %u byte, %u bit slot), %u ch, block=%u",
path, (unsigned)d->info.sample_rate, d->info.bits,
d->out_bytes, (unsigned)d->out_bytes * 8,
d->info.channels, (unsigned)d->info.max_block_size);
return d;
}
int flac_dec_get_info(flac_dec_t *dec, flac_info_t *info)
{
if ((dec == NULL) || (info == NULL) || !dec->header_ready)
{
return -1;
}
*info = dec->info;
return 0;
}
int flac_dec_read(flac_dec_t *d, void *out, int max_frames)
{
if ((d == NULL) || (out == NULL) || (max_frames <= 0))
{
return -1;
}
if (d->failed || !d->header_ready)
{
return -1;
}
const size_t ch = d->info.channels;
const size_t bps = d->bps;
const size_t ob = d->out_bytes;
const size_t src_frame = ch * bps; /* 源每帧字节数 */
uint8_t *dst_base = (uint8_t *)out;
int written = 0;
int stall = 0;
while (written < max_frames)
{
/* ---- 1. 先把已解码但没送出的帧搬走 ---- */
if (d->pcm_pos < d->pcm_bytes)
{
size_t avail = (d->pcm_bytes - d->pcm_pos) / src_frame; /* 剩余帧数 */
size_t need = (size_t)(max_frames - written);
size_t n = (avail < need) ? avail : need;
if (n == 0)
{
d->pcm_pos = d->pcm_bytes; /* 丢弃不足一帧的残料 */
continue;
}
expand_frames(d, d->pcm + d->pcm_pos, dst_base + (size_t)written * 2 * ob, n);
d->pcm_pos += n * src_frame;
written += (int)n;
continue;
}
if (d->stream_end)
{
break;
}
/* ---- 2. 码流用尽则补充 ---- */
if (!has_input(d) && !feed_more(d))
{
d->failed = true;
return (written > 0) ? written : -1;
}
if (!has_input(d))
{
d->stream_end = true; /* 文件读完且没有待解码数据 */
break;
}
/* ---- 3. 解一帧 ---- */
size_t consumed = 0;
size_t samples = 0;
micro_flac::FLACDecoderResult res =
d->decoder->decode(d->in_buf + d->in_pos, d->in_len - d->in_pos,
d->pcm, d->pcm_capacity, consumed, samples);
d->in_pos += consumed;
if (res == micro_flac::FLAC_DECODER_SUCCESS)
{
size_t bytes = samples * bps;
if (bytes > d->pcm_capacity)
{
ESP_LOGE(TAG, "decoder overflow (%u > %u)",
(unsigned)bytes, (unsigned)d->pcm_capacity);
d->failed = true;
return (written > 0) ? written : -1;
}
d->pcm_bytes = bytes;
d->pcm_pos = 0;
/* 无进展保护:空帧且未消费数据 */
if ((bytes == 0) && (consumed == 0))
{
if (++stall > MAX_STALL)
{
ESP_LOGW(TAG, "decode stalled, stop stream");
d->stream_end = true;
break;
}
}
else
{
stall = 0;
}
}
else if (res == micro_flac::FLAC_DECODER_HEADER_READY)
{
/* 正常不会二次出现;出现时忽略即可 */
continue;
}
else if (res == micro_flac::FLAC_DECODER_NEED_MORE_DATA)
{
size_t before = d->in_len - d->in_pos; /* feed 之前的待解码字节数 */
if (!feed_more(d))
{
d->failed = true;
return (written > 0) ? written : -1;
}
/* 文件结束且没有新数据可加 → 剩余数据不足以解出完整帧,正常收尾 */
if ((d->in_len <= before) && d->in_eof)
{
d->stream_end = true;
break;
}
}
else if (res == micro_flac::FLAC_DECODER_END_OF_STREAM)
{
d->stream_end = true;
break;
}
else
{
ESP_LOGE(TAG, "decode error (%d)", (int)res);
d->failed = true;
return (written > 0) ? written : -1;
}
}
return written;
}
void flac_dec_close(flac_dec_t *d)
{
if (d == NULL)
{
return;
}
if (d->fp_open)
{
f_close(&d->fp);
d->fp_open = false;
}
delete d->decoder;
d->decoder = NULL;
free(d->in_buf);
free(d->pcm);
d->in_buf = NULL;
d->pcm = NULL;
delete d;
}
+77
View File
@@ -0,0 +1,77 @@
/**
****************************************************************************************************
* @file flac_dec.h
* @author HiFi Player Project
* @version V1.5
* @date 2026-01-01
* @brief FLAC 解码器 C 接口封装
*
* 底层使用 esphome/micro-flac(ESP32-S3 带 Xtensa 汇编优化的 FLAC 解码器),
* 这里用一层很薄的 C++ 包装,把接口暴露成纯 C,方便 player.c 直接调用。
*
* 输出策略(V1.5 起):
* **保留源文件的位深**,不再统一压到 16bit。
* 库输出的是“原生字节打包 + 左对齐”(16bit→2 字节,24bit→3 字节,32bit→4 字节,
* 20bit 这类非整字节位深则填满其 3 字节的高 20 位),正好符合 I2S 的要求,
* 因此上层可以按 out_bytes 配置 I2S/ES8388 的位宽,实现真正的无损通路。
*
* 声道处理:单声道复制成左右;多于 2 声道时只取前两声道。
****************************************************************************************************
*/
#ifndef __FLAC_DEC_H
#define __FLAC_DEC_H
#include <stdbool.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef struct flac_dec flac_dec_t;
/** FLAC 流信息 */
typedef struct {
uint32_t sample_rate; /* 采样率 */
uint8_t channels; /* 源声道数(1~8) */
uint8_t bits; /* 源位深(4~32) */
uint8_t out_bytes; /* 输出每采样字节数:2 / 3 / 4(对应 I2S 16/24/32bit) */
uint32_t max_block_size; /* 最大块大小(每声道采样数) */
uint64_t total_samples; /* 每声道总采样数,0 表示文件未记录 */
} flac_info_t;
/**
* @brief 打开 FLAC 文件并解析 STREAMINFO
* @param path 文件路径(如 "0:/MUSIC/a.flac")
* @retval 句柄;失败返回 NULL
* @note 返回非 NULL 时 info 已经可用(见 flac_dec_get_info)
*/
flac_dec_t *flac_dec_open(const char *path);
/**
* @brief 获取流信息(仅在 flac_dec_open 成功后有效)
* @retval 0 成功
*/
int flac_dec_get_info(flac_dec_t *dec, flac_info_t *info);
/**
* @brief 读取一块 PCM 数据
* @param dec 解码器句柄
* @param out 输出缓冲区,写入 **立体声交错** 的原生位深 PCM(左对齐)
* @param max_frames 最多输出多少帧(1 帧 = 左右各 1 个采样)
* @retval >0 实际写入的帧数
* 0 文件结束
* <0 解码错误
* @note 每帧占用 2 * out_bytes 字节,调用者需保证缓冲区足够
*/
int flac_dec_read(flac_dec_t *dec, void *out, int max_frames);
/** @brief 关闭并释放所有资源(可传 NULL) */
void flac_dec_close(flac_dec_t *dec);
#ifdef __cplusplus
}
#endif
#endif /* __FLAC_DEC_H */
+770
View File
@@ -0,0 +1,770 @@
/**
****************************************************************************************************
* @file player.c
* @author HiFi Player Project
* @version V1.4
* @date 2026-01-01
* @brief HiFi 播放器核心实现(WAV / FLAC 播放 + I2S 输出)
*
* 与其他模块的关系:
* - 按键由 ui.c 统一消费,再通过 player_request() 投递动作请求到本模块(位标记,无阻塞)。
* - 播放状态写入 s_state(互斥锁保护),播放器界面只读取快照。
* - 只有处于 APP_MODE_PLAYER 时才输出音频;离开该模式自动关闭 I2S 并保留播放位置。
*
* 音频格式支持由 audio_source.c 统一处理:
* WAV —— 线性 PCM 16/24/32bit(无需解码库,直接取高位)
* FLAC —— 原生 FLAC / Ogg FLAC(esphome/micro-flac 解码)
* 两者对外都输出 16bit 立体声交错 PCM,因此本文件的播放循环与格式无关。
****************************************************************************************************
*/
#include "player.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "esp_heap_caps.h"
#include "esp_log.h"
#include "nvs.h"
#include "ff.h"
#include "spi_sd.h"
#include "audio_source.h"
#include "es8388.h"
#include "myi2s.h"
#include "xl9555.h"
#include "led.h"
#define TAG "player"
/* ---------------- 播放器参数 ---------------- */
#define MUSIC_DIR "0:/MUSIC" /* SD 卡音乐目录 */
#define MAX_TRACKS 200 /* 最多扫描的曲目数 */
#define AUDIO_CHUNK_FRAMES 1024 /* 每次送给 I2S 的帧数(约 23ms @44.1kHz) */
#define AUDIO_BUF_SIZE (AUDIO_CHUNK_FRAMES * 8) /* 32bit 立体声 = 8 字节/帧 */
#define POLL_MS 20 /* 挂起/暂停时的轮询周期 */
#define TASK_STACK_SIZE (6 * 1024)
#define TASK_PRIORITY 6 /* 高于 LVGL 任务,保证音频不断流 */
/* ---------------- 内部类型 ---------------- */
typedef struct {
char path[300]; /* 完整路径:0:/MUSIC/xxx.wav|flac */
char name[64]; /* 显示名(去扩展名、仅 ASCII) */
} track_info_t;
typedef enum {
TRACK_RESULT_END = 0, /* 正常播完 */
TRACK_RESULT_NEXT, /* 用户按了下一曲 / 文件不支持 */
TRACK_RESULT_ERROR, /* 文件 / SD 卡错误 */
} track_result_t;
/* ---------------- 全局状态 ---------------- */
static player_state_t s_state;
static SemaphoreHandle_t s_lock = NULL;
static track_info_t *s_tracks = NULL;
static uint8_t *s_audio_buf = NULL;
static bool s_audio_ready = false;
/* 动作请求:UI 任务写入,播放任务读取(位标记累积) */
static portMUX_TYPE s_req_lock = portMUX_INITIALIZER_UNLOCKED;
static volatile uint32_t s_requests = 0;
static const uint8_t s_vol_steps[] = {20, 40, 60, 80, 100};
#define VOL_STEP_NUM (sizeof(s_vol_steps) / sizeof(s_vol_steps[0]))
static uint8_t s_vol_percent = 60;
/* ---------------- 状态访问(互斥锁) ---------------- */
static void state_lock(void)
{
if (s_lock != NULL)
{
xSemaphoreTake(s_lock, portMAX_DELAY);
}
}
static void state_unlock(void)
{
if (s_lock != NULL)
{
xSemaphoreGive(s_lock);
}
}
static void state_set_message(player_status_t status, const char *message)
{
state_lock();
s_state.status = status;
if (message != NULL)
{
strncpy(s_state.message, message, sizeof(s_state.message) - 1);
s_state.message[sizeof(s_state.message) - 1] = '\0';
}
state_unlock();
}
static void state_clear_track(void)
{
state_lock();
s_state.track_name[0] = '\0';
s_state.codec[0] = '\0';
s_state.track_index = 0;
s_state.track_total = 0;
s_state.cur_sec = 0;
s_state.tot_sec = 0;
s_state.samplerate = 0;
s_state.bits = 0;
s_state.channels = 0;
s_state.bitrate = 0;
state_unlock();
}
/* ---------------- 动作请求 ---------------- */
static uint32_t take_requests(void)
{
uint32_t reqs;
taskENTER_CRITICAL(&s_req_lock);
reqs = s_requests;
s_requests = 0;
taskEXIT_CRITICAL(&s_req_lock);
return reqs;
}
void player_request(player_request_t req)
{
if ((unsigned)req >= PLAYER_REQ_NUM)
{
return;
}
taskENTER_CRITICAL(&s_req_lock);
s_requests |= (1u << (uint32_t)req);
taskEXIT_CRITICAL(&s_req_lock);
}
/* ---------------- 音量 ---------------- */
static void volume_apply(void)
{
uint32_t reg = (uint32_t)s_vol_percent * 33 / 100;
if (reg > 33)
{
reg = 33;
}
if (s_audio_ready)
{
es8388_hpvol_set((uint8_t)reg); /* 耳机音量 */
es8388_spkvol_set((uint8_t)reg); /* 喇叭音量 */
}
state_lock();
s_state.volume = s_vol_percent;
state_unlock();
}
static void volume_load(void)
{
nvs_handle_t handle;
uint8_t value = 0;
if (nvs_open("hifi", NVS_READONLY, &handle) == ESP_OK)
{
if ((nvs_get_u8(handle, "vol", &value) == ESP_OK) && (value <= 100))
{
s_vol_percent = value;
}
nvs_close(handle);
}
}
static void volume_save(void)
{
nvs_handle_t handle;
if (nvs_open("hifi", NVS_READWRITE, &handle) == ESP_OK)
{
nvs_set_u8(handle, "vol", s_vol_percent);
nvs_commit(handle);
nvs_close(handle);
}
}
static void volume_next(void)
{
uint32_t index = 0;
for (uint32_t i = 0; i < VOL_STEP_NUM; i++)
{
if (s_vol_percent == s_vol_steps[i])
{
index = i;
break;
}
}
index = (index + 1) % VOL_STEP_NUM;
s_vol_percent = s_vol_steps[index];
volume_apply();
volume_save();
ESP_LOGI(TAG, "volume -> %u%%", s_vol_percent);
}
/* ---------------- 音频硬件 ---------------- */
static bool audio_init(void)
{
if (es8388_init() != 0)
{
ESP_LOGE(TAG, "ES8388 init failed");
return false;
}
s_audio_ready = true;
es8388_adda_cfg(1, 0); /* 打开 DAC,关闭 ADC */
es8388_input_cfg(0); /* 关闭录音输入 */
es8388_output_cfg(1, 1); /* 耳机 + 喇叭通道都打开 */
es8388_3d_set(0); /* 关闭 3D 环绕 */
es8388_i2s_cfg(0, 3); /* Philips I2S,16bit */
volume_apply();
if (myi2s_init() != ESP_OK)
{
return false;
}
i2s_trx_stop(); /* 空闲时先关闭通道 */
xl9555_pin_write(SPK_EN_IO, 0); /* 打开板载喇叭功放(低电平有效) */
vTaskDelay(pdMS_TO_TICKS(50));
return true;
}
/**
* @brief 按源文件的采样率与位深配置 I2S / ES8388
* @param sample_rate 采样率(Hz)
* @param bits I2S 输出位宽:16 / 24 / 32
* @note 位宽不同会重新配置 I2S 的 data_bit_width / ws_width 与 ES8388 的 DAC 字长,
* 因此 16bit、24bit、32bit 文件之间切换都是“真正按格式输出”,不做降位。
*/
static void audio_set_format(uint32_t sample_rate, uint16_t bits)
{
uint8_t es_len; /* ES8388 R23 的数据字长字段 */
switch (bits)
{
case 24:
es_len = 0; /* 24bit */
break;
case 32:
es_len = 4; /* 32bit */
break;
case 16:
default:
es_len = 3; /* 16bit */
bits = 16;
break;
}
es8388_i2s_cfg(0, es_len); /* Philips I2S + 对应字长 */
i2s_set_samplerate_bits_sample((int)sample_rate, (int)bits);
/* 通道保持关闭,由播放循环在真正要出声时再 i2s_trx_start() */
ESP_LOGI(TAG, "audio: %u Hz / %u bit", (unsigned)sample_rate, bits);
}
/* ---------------- 曲目扫描 ---------------- */
/**
* @brief 判断是否为可播放的音频文件(.wav / .flac)
*/
static bool name_is_audio(const char *name)
{
size_t len = strlen(name);
if (len < 5)
{
return false;
}
const char *ext = name + len - 4; /* 指向 ".xxx" */
if (ext[0] != '.')
{
return false;
}
/* .wav */
if (((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;
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;
}
}
return false;
}
/**
* @brief 生成用于 LVGL 显示的曲目名(去掉扩展名,非 ASCII 字符用 '?' 代替)
*/
static void make_display_name(const char *fname, char *out, size_t out_size)
{
size_t len = strlen(fname);
size_t end = len;
size_t j = 0;
bool last_unknown = false;
for (size_t i = len; i > 0; i--)
{
if (fname[i - 1] == '.')
{
end = i - 1;
break;
}
}
for (size_t i = 0; (i < end) && (j + 1 < out_size); i++)
{
unsigned char c = (unsigned char)fname[i];
if ((c >= 0x20) && (c < 0x7F))
{
out[j++] = (char)c;
last_unknown = (c == '?');
}
else if (!last_unknown)
{
/* 中文文件名是 GBK 编码,LVGL 内置字体无法显示,统一用 '?' 占位 */
out[j++] = '?';
last_unknown = true;
}
}
out[j] = '\0';
}
static int playlist_scan(void)
{
FF_DIR dir;
FILINFO *info = NULL;
FRESULT res;
int count = 0;
if (s_tracks == NULL)
{
return 0;
}
info = (FILINFO *)malloc(sizeof(FILINFO));
if (info == NULL)
{
ESP_LOGE(TAG, "no memory for FILINFO");
return 0;
}
res = f_opendir(&dir, MUSIC_DIR);
if (res == FR_OK)
{
while (count < MAX_TRACKS)
{
res = f_readdir(&dir, info);
if ((res != FR_OK) || (info->fname[0] == '\0'))
{
break;
}
if (info->fattrib & AM_DIR)
{
continue;
}
if (!name_is_audio(info->fname))
{
continue;
}
snprintf(s_tracks[count].path, sizeof(s_tracks[count].path),
"%s/%s", MUSIC_DIR, info->fname);
make_display_name(info->fname, s_tracks[count].name, sizeof(s_tracks[count].name));
count++;
}
f_closedir(&dir);
}
else
{
ESP_LOGW(TAG, "open %s failed (%d)", MUSIC_DIR, res);
}
free(info);
state_lock();
s_state.track_total = (int16_t)count;
state_unlock();
ESP_LOGI(TAG, "found %d audio file(s) in %s", count, MUSIC_DIR);
return count;
}
/* ---------------- 单曲播放 ---------------- */
/**
* @brief 播放一首曲子(WAV / FLAC 通用)
* @retval TRACK_RESULT_END 正常播完
* TRACK_RESULT_NEXT 需要跳到下一首(用户操作或不支持的文件)
* TRACK_RESULT_ERROR 文件系统 / SD 卡错误
*/
static track_result_t play_track(int index)
{
audio_source_t *src = NULL;
audio_info_t info;
track_result_t result = TRACK_RESULT_END;
bool paused = false;
bool audio_running = false;
bool active = (app_get_mode() == APP_MODE_PLAYER); /* 是否停留在播放器界面 */
uint32_t last_sec = 0xFFFFFFFF;
if ((s_audio_buf == NULL) || (s_tracks == NULL) ||
(index < 0) || (index >= MAX_TRACKS) || (s_tracks[index].path[0] == '\0'))
{
return TRACK_RESULT_ERROR;
}
memset(&info, 0, sizeof(info));
src = audio_source_open(s_tracks[index].path, &info);
if (src == NULL)
{
/* 不支持的格式或文件损坏:跳过,继续下一首 */
ESP_LOGW(TAG, "cannot open %s", s_tracks[index].path);
state_set_message(PLAYER_STATUS_ERROR, "UNSUPPORTED FILE");
return TRACK_RESULT_NEXT;
}
/* ---------------- 更新界面状态 ---------------- */
state_lock();
s_state.track_index = (int16_t)index;
strncpy(s_state.track_name, s_tracks[index].name, sizeof(s_state.track_name) - 1);
s_state.track_name[sizeof(s_state.track_name) - 1] = '\0';
snprintf(s_state.codec, sizeof(s_state.codec), "%s",
(info.codec == AUDIO_CODEC_FLAC) ? "FLAC" : "WAV");
s_state.samplerate = (uint16_t)info.sample_rate;
s_state.bits = (uint8_t)info.bits;
s_state.i2s_bits = (uint8_t)info.out_bits;
s_state.channels = (uint8_t)info.channels;
s_state.bitrate = (uint32_t)info.sample_rate * info.channels * info.out_bits;
s_state.cur_sec = 0;
s_state.tot_sec = info.total_sec;
s_state.status = active ? PLAYER_STATUS_PLAYING : PLAYER_STATUS_PAUSED;
strncpy(s_state.message, active ? "PLAYING" : "READY", sizeof(s_state.message) - 1);
s_state.message[sizeof(s_state.message) - 1] = '\0';
state_unlock();
ESP_LOGI(TAG, "load [%d] %s (%s %u Hz, %u bit -> I2S %u bit, %u ch, %u s)",
index + 1, s_tracks[index].name,
(info.codec == AUDIO_CODEC_FLAC) ? "FLAC" : "WAV",
(unsigned)info.sample_rate, info.bits, info.out_bits,
info.channels, (unsigned)info.total_sec);
/* ---------------- 配置音频(按源文件采样率 + 原生位深) ---------------- */
audio_set_format(info.sample_rate, info.out_bits);
while (1)
{
/* ---- 1. 只有停留在播放器界面才输出音频 ---- */
if (app_get_mode() != APP_MODE_PLAYER)
{
if (audio_running)
{
i2s_trx_stop();
audio_running = false;
}
if (active) /* 刚从播放器返回主界面 */
{
active = false;
LED0(0);
state_set_message(PLAYER_STATUS_PAUSED, "PAUSED");
}
vTaskDelay(pdMS_TO_TICKS(POLL_MS)); /* 后台挂起,不占用 CPU */
continue;
}
if (!active) /* 从主界面重新进入播放器 */
{
active = true;
paused = false;
take_requests(); /* 丢弃进入播放器前残留的请求 */
state_set_message(PLAYER_STATUS_PLAYING, "PLAYING");
}
/* ---- 2. 处理 UI 投递的请求 ---- */
uint32_t reqs = take_requests();
bool quit_track = false;
if (reqs & (1u << PLAYER_REQ_NEXT))
{
result = TRACK_RESULT_NEXT;
quit_track = true;
}
if (!quit_track && (reqs & (1u << PLAYER_REQ_PLAY_PAUSE)))
{
paused = !paused;
state_set_message(paused ? PLAYER_STATUS_PAUSED : PLAYER_STATUS_PLAYING,
paused ? "PAUSED" : "PLAYING");
}
if (!quit_track && (reqs & (1u << PLAYER_REQ_VOLUME)))
{
volume_next();
}
if (quit_track)
{
break;
}
if (app_get_mode() != APP_MODE_PLAYER)
{
continue; /* 已切回主界面 */
}
/* ---- 3. 暂停时不输出音频,但保持解码位置 ---- */
bool should_run = !paused;
if (should_run && !audio_running)
{
i2s_trx_start();
audio_running = true;
LED0(1);
}
else if (!should_run && audio_running)
{
i2s_trx_stop();
audio_running = false;
LED0(0);
}
if (!should_run)
{
vTaskDelay(pdMS_TO_TICKS(POLL_MS));
continue;
}
/* ---- 4. 解一块 PCM(WAV 直读 / FLAC 解码,立体声 + 源文件原生位深) ---- */
int frames = audio_source_read(src, (void *)s_audio_buf, AUDIO_CHUNK_FRAMES);
if (frames < 0)
{
ESP_LOGE(TAG, "decode/read error");
result = TRACK_RESULT_ERROR;
break;
}
if (frames == 0)
{
result = TRACK_RESULT_END;
break;
}
/* 每帧 = 左右声道 × out_bytes 字节(16bit→4,24bit→6,32bit→8) */
i2s_tx_write(s_audio_buf, (uint32_t)frames * 2u * info.out_bytes);
/* ---- 5. 更新播放进度 ---- */
uint32_t sec = audio_source_elapsed_sec(src);
if (sec != last_sec)
{
last_sec = sec;
state_lock();
s_state.cur_sec = sec;
state_unlock();
}
}
/* 关闭 I2S,避免切歌/退出时产生杂音 */
if (audio_running)
{
i2s_trx_stop();
}
LED0(0);
audio_source_close(src);
return result;
}
/* ---------------- 播放器主任务 ---------------- */
static void player_task(void *arg)
{
bool sd_ready = false;
bool audio_ok = false;
int index = 0;
(void)arg;
audio_ok = audio_init();
if (!audio_ok)
{
state_set_message(PLAYER_STATUS_ERROR, "AUDIO INIT FAILED");
}
s_audio_buf = (uint8_t *)heap_caps_malloc(AUDIO_BUF_SIZE, MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL);
s_tracks = (track_info_t *)calloc(MAX_TRACKS, sizeof(track_info_t));
if ((s_audio_buf == NULL) || (s_tracks == NULL))
{
ESP_LOGE(TAG, "no memory for player buffers");
state_set_message(PLAYER_STATUS_ERROR, "OUT OF MEMORY");
vTaskDelete(NULL);
return;
}
ESP_LOGI(TAG, "player task started");
while (1)
{
/* ---------------- 0. 空闲时也响应音量(不依赖 SD 卡) ---------------- */
uint32_t idle_reqs = take_requests();
if (idle_reqs & (1u << PLAYER_REQ_VOLUME))
{
volume_next();
}
/* ---------------- 1. 音频硬件异常 ---------------- */
if (!audio_ok)
{
state_set_message(PLAYER_STATUS_ERROR, "AUDIO INIT FAILED");
vTaskDelay(pdMS_TO_TICKS(2000));
continue;
}
/* ---------------- 2. 挂载 SD 卡 ---------------- */
if (!sd_ready)
{
state_set_message(PLAYER_STATUS_BOOT, "MOUNTING SD CARD...");
if (sd_spi_init() == ESP_OK)
{
sd_ready = true;
ESP_LOGI(TAG, "sd card mounted");
}
else
{
state_clear_track();
state_set_message(PLAYER_STATUS_NO_SD, "NO SD CARD");
vTaskDelay(pdMS_TO_TICKS(1000));
continue;
}
}
/* ---------------- 3. 扫描曲目 ---------------- */
int total = playlist_scan();
if (total == 0)
{
state_clear_track();
state_set_message(PLAYER_STATUS_NO_MUSIC, "PUT WAV/FLAC IN /MUSIC");
vTaskDelay(pdMS_TO_TICKS(1500));
continue;
}
if (index >= total)
{
index = 0;
}
/* ---------------- 4. 播放当前曲目 ---------------- */
track_result_t result = play_track(index);
if ((result == TRACK_RESULT_NEXT) || (result == TRACK_RESULT_END))
{
index = (index + 1) % total; /* 循环播放列表(没有上一首) */
}
else
{
/* 文件系统 / SD 卡异常:重新挂载 */
sd_ready = false;
state_clear_track();
state_set_message(PLAYER_STATUS_NO_SD, "SD CARD ERROR");
vTaskDelay(pdMS_TO_TICKS(500));
}
}
}
/* ---------------- 对外接口 ---------------- */
void player_start(void)
{
if (s_lock == NULL)
{
s_lock = xSemaphoreCreateMutex();
if (s_lock == NULL)
{
ESP_LOGE(TAG, "create mutex failed");
}
}
volume_load();
memset(&s_state, 0, sizeof(s_state));
s_state.status = PLAYER_STATUS_BOOT;
s_state.volume = s_vol_percent;
snprintf(s_state.message, sizeof(s_state.message), "STARTING...");
if (xTaskCreate(player_task, "player", TASK_STACK_SIZE, NULL, TASK_PRIORITY, NULL) != pdPASS)
{
ESP_LOGE(TAG, "create player task failed");
}
}
void player_get_state(player_state_t *out)
{
if (out == NULL)
{
return;
}
if ((s_lock != NULL) && (xSemaphoreTake(s_lock, pdMS_TO_TICKS(50)) == pdTRUE))
{
*out = s_state;
xSemaphoreGive(s_lock);
}
else
{
*out = s_state;
}
}
+87
View File
@@ -0,0 +1,87 @@
/**
****************************************************************************************************
* @file player.h
* @author HiFi Player Project
* @version V1.2
* @date 2026-01-01
* @brief HiFi 播放器核心:SD 卡曲目扫描、WAV 解码、I2S 输出
* @note 按键由 ui.c 统一消费后通过 player_request() 转发进来;本模块不直接读按键。
* 播放器只在 APP_MODE_PLAYER 模式下输出音频;离开该模式会自动暂停并保留进度。
****************************************************************************************************
*/
#ifndef __PLAYER_H
#define __PLAYER_H
#include <stdbool.h>
#include <stdint.h>
#include "app.h"
#ifdef __cplusplus
extern "C" {
#endif
/* ---------------- 播放器状态 ---------------- */
typedef enum {
PLAYER_STATUS_BOOT = 0, /* 正在初始化 / 挂载 SD 卡 */
PLAYER_STATUS_NO_SD, /* 没有检测到 SD 卡 */
PLAYER_STATUS_NO_MUSIC, /* SD 卡中没有可播放的 WAV 文件 */
PLAYER_STATUS_PLAYING, /* 正在播放 */
PLAYER_STATUS_PAUSED, /* 已暂停 */
PLAYER_STATUS_ERROR, /* 音频硬件 / 文件系统错误 */
} player_status_t;
/* ---------------- 供 UI 读取的状态快照 ---------------- */
typedef struct {
player_status_t status; /* 当前状态 */
uint8_t volume; /* 音量百分比 0~100 */
int16_t track_index; /* 当前曲目序号(0 基) */
int16_t track_total; /* 曲目总数 */
uint32_t cur_sec; /* 当前播放秒数 */
uint32_t tot_sec; /* 曲目总秒数 */
uint16_t samplerate; /* 采样率 */
uint8_t bits; /* 源位深(16/24/32,界面显示用) */
uint8_t i2s_bits; /* 实际 I2S 输出位宽(16/24/32) */
uint8_t channels; /* 声道数 */
uint32_t bitrate; /* 比特率,单位 bps */
char track_name[64]; /* 曲目显示名(已去掉扩展名,仅保留 ASCII) */
char codec[8]; /* 容器格式:"WAV" / "FLAC" */
char message[64]; /* 状态提示信息 */
} player_state_t;
/* ---------------- 播放器可接收的请求 ---------------- */
typedef enum {
PLAYER_REQ_PLAY_PAUSE = 0, /* 播放 / 暂停 */
PLAYER_REQ_NEXT, /* 下一曲 */
PLAYER_REQ_VOLUME, /* 音量切档 */
PLAYER_REQ_NUM,
} player_request_t;
/**
* @brief 启动播放器(创建播放任务)
* @note 调用前必须完成 NVS / SPI / IIC / XL9555 / LCD 初始化
*/
void player_start(void);
/**
* @brief 获取播放器状态快照(线程安全,供 LVGL 任务调用)
* @param out 输出缓冲区
*/
void player_get_state(player_state_t *out);
/**
* @brief 向播放任务投递一个动作请求(可在任意任务中调用,不阻塞)
* @param req 请求类型
* @note 请求是位标记累积的,播放任务下次循环时统一处理
*/
void player_request(player_request_t req);
#ifdef __cplusplus
}
#endif
#endif /* __PLAYER_H */
+323
View File
@@ -0,0 +1,323 @@
/**
****************************************************************************************************
* @file ui_player.c
* @author HiFi Player Project
* @version V1.2
* @date 2026-01-01
* @brief 播放器界面
*
* ┌──────────────────────────┐
* │ ♪ HiFi PLAYER 01/12 │
* ├──────────────────────────┤
* │ ┌────┐ 歌名 │
* │ │ ♪ │ WAV 44100Hz 16bit │
* │ └────┘ ( PLAYING ) │
* │ 00:42 ────●────── 03:35 │
* │ 🔊 ──────●───── 60% │
* ├──────────────────────────┤
* │ [←K0] [⏯K1] [⏭K2] [🔊K3] │
* │ BACK PLAY NEXT VOL │
* └──────────────────────────┘
****************************************************************************************************
*/
#include "ui_player.h"
#include <stdio.h>
#include <string.h>
#include "ui_common.h"
#include "app.h"
#include "player.h"
/* ---------------- 控件 ---------------- */
static lv_obj_t *s_scr = NULL;
static lv_obj_t *s_lbl_index = NULL;
static lv_obj_t *s_lbl_name = NULL;
static lv_obj_t *s_lbl_format = NULL;
static lv_obj_t *s_lbl_chip = NULL;
static lv_obj_t *s_bar_prog = NULL;
static lv_obj_t *s_lbl_cur = NULL;
static lv_obj_t *s_lbl_tot = NULL;
static lv_obj_t *s_bar_vol = NULL;
static lv_obj_t *s_lbl_vol = NULL;
static ui_key_bar_t s_bar;
static ui_flash_t s_flash;
static uint32_t s_chip_color = 0xFFFFFFFF;
/* ---------------- 按键提示内容 ---------------- */
static const char *const s_symbols[APP_KEY_NUM] = {
LV_SYMBOL_LEFT, /* K0:返回主界面 */
LV_SYMBOL_PLAY, /* K1:播放 / 暂停(图标随状态切换) */
LV_SYMBOL_NEXT, /* K2:下一曲 */
LV_SYMBOL_VOLUME_MAX, /* K3:音量 */
};
static const char *const s_captions[APP_KEY_NUM] = {
"K0 BACK",
"K1 PLAY",
"K2 NEXT",
"K3 VOL",
};
static const bool s_enabled[APP_KEY_NUM] = { true, true, true, true };
/* ---------------- 状态显示 ---------------- */
static const char *status_text(player_status_t status, const char *message)
{
switch (status)
{
case PLAYER_STATUS_PLAYING:
return "PLAYING";
case PLAYER_STATUS_PAUSED:
return (message && message[0]) ? message : "PAUSED";
case PLAYER_STATUS_NO_SD:
return (message && message[0]) ? message : "NO SD CARD";
case PLAYER_STATUS_NO_MUSIC:
return (message && message[0]) ? message : "NO MUSIC";
case PLAYER_STATUS_ERROR:
return (message && message[0]) ? message : "ERROR";
case PLAYER_STATUS_BOOT:
default:
return (message && message[0]) ? message : "STARTING...";
}
}
static uint32_t status_color(player_status_t status)
{
switch (status)
{
case PLAYER_STATUS_PLAYING:
return UI_COL_OK;
case PLAYER_STATUS_PAUSED:
return UI_COL_WARN;
case PLAYER_STATUS_NO_SD:
case PLAYER_STATUS_ERROR:
return UI_COL_ERR;
case PLAYER_STATUS_NO_MUSIC:
return UI_COL_WARN;
case PLAYER_STATUS_BOOT:
default:
return UI_COL_SUB;
}
}
/* ---------------- 创建界面 ---------------- */
void ui_player_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, 30, UI_COL_PANEL, 0);
lv_obj_t *title = ui_label(header, LV_SYMBOL_AUDIO " HiFi PLAYER",
&lv_font_montserrat_16, UI_COL_ACCENT);
lv_obj_align(title, LV_ALIGN_LEFT_MID, 10, 0);
s_lbl_index = ui_label(header, "--/--", &lv_font_montserrat_14, UI_COL_SUB);
lv_obj_align(s_lbl_index, LV_ALIGN_RIGHT_MID, -10, 0);
/* ---------------- 封面卡片 ---------------- */
lv_obj_t *cover = ui_card(s_scr, 10, 36, 92, 92, 0x283A6B, 12);
lv_obj_set_style_bg_grad_color(cover, lv_color_hex(UI_COL_ACCENT2), LV_PART_MAIN);
lv_obj_set_style_bg_grad_dir(cover, LV_GRAD_DIR_VER, LV_PART_MAIN);
lv_obj_set_style_shadow_width(cover, 18, LV_PART_MAIN);
lv_obj_set_style_shadow_color(cover, lv_color_hex(UI_COL_ACCENT2), LV_PART_MAIN);
lv_obj_set_style_shadow_opa(cover, LV_OPA_30, LV_PART_MAIN);
lv_obj_t *note = ui_label(cover, LV_SYMBOL_AUDIO, &lv_font_montserrat_40, 0xFFFFFF);
lv_obj_center(note);
/* ---------------- 曲目信息 ---------------- */
s_lbl_name = ui_label(s_scr, "No media", &lv_font_montserrat_16, UI_COL_TEXT);
lv_obj_set_pos(s_lbl_name, 112, 36);
lv_obj_set_size(s_lbl_name, 198, 20);
lv_label_set_long_mode(s_lbl_name, LV_LABEL_LONG_SCROLL_CIRCULAR);
s_lbl_format = ui_label(s_scr, "WAV / PCM", &lv_font_montserrat_12, UI_COL_SUB);
lv_obj_set_pos(s_lbl_format, 112, 60);
lv_obj_t *chip = ui_card(s_scr, 112, 82, LV_SIZE_CONTENT, 22, UI_COL_CARD, 11);
lv_obj_set_style_pad_hor(chip, 8, LV_PART_MAIN);
lv_obj_set_style_border_width(chip, 1, LV_PART_MAIN);
lv_obj_set_style_border_color(chip, lv_color_hex(UI_COL_TRACK), LV_PART_MAIN);
s_lbl_chip = ui_label(chip, "STARTING...", &lv_font_montserrat_12, UI_COL_SUB);
lv_obj_center(s_lbl_chip);
/* ---------------- 播放进度 ---------------- */
s_bar_prog = lv_bar_create(s_scr);
lv_obj_set_pos(s_bar_prog, 10, 130);
lv_obj_set_size(s_bar_prog, 300, 8);
lv_bar_set_range(s_bar_prog, 0, 100);
lv_bar_set_value(s_bar_prog, 0, LV_ANIM_OFF);
lv_obj_set_style_bg_color(s_bar_prog, lv_color_hex(UI_COL_TRACK), LV_PART_MAIN);
lv_obj_set_style_bg_opa(s_bar_prog, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_radius(s_bar_prog, 4, LV_PART_MAIN);
lv_obj_set_style_bg_color(s_bar_prog, lv_color_hex(UI_COL_ACCENT), LV_PART_INDICATOR);
lv_obj_set_style_radius(s_bar_prog, 4, LV_PART_INDICATOR);
s_lbl_cur = ui_label(s_scr, "00:00", &lv_font_montserrat_12, UI_COL_SUB);
lv_obj_set_pos(s_lbl_cur, 10, 142);
s_lbl_tot = ui_label(s_scr, "--:--", &lv_font_montserrat_12, UI_COL_SUB);
lv_obj_align(s_lbl_tot, LV_ALIGN_TOP_RIGHT, -10, 142);
/* ---------------- 音量 ---------------- */
lv_obj_t *vol_icon = ui_label(s_scr, LV_SYMBOL_VOLUME_MAX, &lv_font_montserrat_14,
UI_COL_ACCENT);
lv_obj_set_pos(vol_icon, 10, 160);
s_bar_vol = lv_bar_create(s_scr);
lv_obj_set_pos(s_bar_vol, 32, 165);
lv_obj_set_size(s_bar_vol, 228, 6);
lv_bar_set_range(s_bar_vol, 0, 100);
lv_bar_set_value(s_bar_vol, 60, LV_ANIM_OFF);
lv_obj_set_style_bg_color(s_bar_vol, lv_color_hex(UI_COL_TRACK), LV_PART_MAIN);
lv_obj_set_style_bg_opa(s_bar_vol, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_radius(s_bar_vol, 3, LV_PART_MAIN);
lv_obj_set_style_bg_color(s_bar_vol, lv_color_hex(UI_COL_ACCENT2), LV_PART_INDICATOR);
lv_obj_set_style_radius(s_bar_vol, 3, LV_PART_INDICATOR);
s_lbl_vol = ui_label(s_scr, "60%", &lv_font_montserrat_12, UI_COL_SUB);
lv_obj_align(s_lbl_vol, LV_ALIGN_TOP_RIGHT, -10, 158);
/* ---------------- 底部按键提示 ---------------- */
ui_key_bar_create(s_scr, &s_bar, s_symbols, s_captions, s_enabled);
}
/* ---------------- 显示 / 刷新 ---------------- */
void ui_player_show(void)
{
ui_key_bar_set_highlight(&s_bar, -1);
ui_player_tick();
lv_scr_load(s_scr);
}
void ui_player_tick(void)
{
player_state_t state;
char buffer[96];
player_get_state(&state);
/* ---- 标题栏:曲目序号 ---- */
if (state.track_total > 0)
{
snprintf(buffer, sizeof(buffer), "%02d/%02d",
(int)state.track_index + 1, (int)state.track_total);
}
else
{
snprintf(buffer, sizeof(buffer), "--/--");
}
ui_label_set_if_changed(s_lbl_index, buffer);
/* ---- 曲名 / 格式 / 状态 ---- */
ui_label_set_if_changed(s_lbl_name, state.track_name[0] ? state.track_name : "No media");
if (state.tot_sec > 0)
{
/* 位数相同只显示一个;源位深被提升时显示 "24->32bit" */
char bits_text[16];
uint8_t i2s_bits = state.i2s_bits ? state.i2s_bits : state.bits;
if (i2s_bits == state.bits)
{
snprintf(bits_text, sizeof(bits_text), "%ubit", (unsigned)state.bits);
}
else
{
snprintf(bits_text, sizeof(bits_text), "%u->%ubit",
(unsigned)state.bits, (unsigned)i2s_bits);
}
snprintf(buffer, sizeof(buffer), "%s %uHz %s %s",
state.codec[0] ? state.codec : "AUDIO",
(unsigned)state.samplerate, bits_text,
(state.channels == 1) ? "MONO" : "STEREO");
}
else
{
snprintf(buffer, sizeof(buffer), "%s / PCM",
state.codec[0] ? state.codec : "AUDIO");
}
ui_label_set_if_changed(s_lbl_format, buffer);
ui_label_set_if_changed(s_lbl_chip, status_text(state.status, state.message));
uint32_t color = status_color(state.status);
if (color != s_chip_color)
{
s_chip_color = color;
lv_obj_set_style_text_color(s_lbl_chip, lv_color_hex(color), LV_PART_MAIN);
}
/* ---- 播放进度 ---- */
uint32_t total = (state.tot_sec > 0) ? state.tot_sec : 1;
uint32_t current = (state.cur_sec > state.tot_sec) ? state.tot_sec : state.cur_sec;
lv_bar_set_value(s_bar_prog, (int32_t)((uint64_t)current * 100 / total), LV_ANIM_OFF);
ui_format_time(current, buffer, sizeof(buffer));
ui_label_set_if_changed(s_lbl_cur, buffer);
if (state.tot_sec > 0)
{
ui_format_time(state.tot_sec, buffer, sizeof(buffer));
}
else
{
snprintf(buffer, sizeof(buffer), "--:--");
}
ui_label_set_if_changed(s_lbl_tot, buffer);
/* ---- 音量 ---- */
lv_bar_set_value(s_bar_vol, (int32_t)state.volume, LV_ANIM_OFF);
snprintf(buffer, sizeof(buffer), "%u%%", (unsigned)state.volume);
ui_label_set_if_changed(s_lbl_vol, buffer);
/* ---- 播放键图标提示“按下后会发生什么” ---- */
ui_key_bar_set_icon(&s_bar, APP_KEY_1,
(state.status == PLAYER_STATUS_PLAYING) ? LV_SYMBOL_PAUSE
: LV_SYMBOL_PLAY);
/* ---- 按键高亮 ---- */
ui_key_bar_set_highlight(&s_bar, ui_flash_active(&s_flash));
}
/* ---------------- 按键 ---------------- */
void ui_player_flash(int key)
{
ui_flash_trigger(&s_flash, key);
}
void ui_player_handle_key(uint8_t key)
{
ui_flash_trigger(&s_flash, key);
switch (key)
{
case APP_KEY_1:
player_request(PLAYER_REQ_PLAY_PAUSE);
break;
case APP_KEY_2:
player_request(PLAYER_REQ_NEXT);
break;
case APP_KEY_3:
player_request(PLAYER_REQ_VOLUME);
break;
default:
break;
}
}
+43
View File
@@ -0,0 +1,43 @@
/**
****************************************************************************************************
* @file ui_player.h
* @author HiFi Player Project
* @version V1.2
* @date 2026-01-01
* @brief 播放器界面接口
*
* 按键:K0 返回主界面 / K1 播放暂停 / K2 下一曲 / K3 音量
****************************************************************************************************
*/
#ifndef __UI_PLAYER_H
#define __UI_PLAYER_H
#include <stdint.h>
#include "lvgl.h"
#ifdef __cplusplus
extern "C" {
#endif
/** 创建播放器界面(只创建,不显示) */
void ui_player_create(void);
/** 显示播放器界面并立即刷新一次 */
void ui_player_show(void);
/** 周期刷新界面数据(进度、音量、状态等) */
void ui_player_tick(void);
/** 处理本界面的功能键(K1/K2/K3);K0 由 ui.c 统一处理 */
void ui_player_handle_key(uint8_t key);
/** 仅触发按键高亮(不做动作),供 K0 使用 */
void ui_player_flash(int key);
#ifdef __cplusplus
}
#endif
#endif /* __UI_PLAYER_H */
+150
View File
@@ -0,0 +1,150 @@
/**
****************************************************************************************************
* @file ui.c
* @author HiFi Player Project
* @version V1.2
* @date 2026-01-01
* @brief 界面路由器:模式切换 + 按键事件分发
*
* ---------------------------------------------------------------------------------------------------
* 全局按键约定(K0 为“返回/切换”键,任何 App 都遵循同一套语义)
* ---------------------------------------------------------------------------------------------------
* K0 短按 在任意 App 内 -> 返回主界面
* 在主界面 -> 切换到下一个应用
* K0 长按 任意界面 -> 直接切换到下一个应用
* K1/K2/K3 由当前 App 自己定义(见各 App 界面底部的按键提示)
*
* 因此新增 App 时,只需实现自己的 K1/K2/K3,返回与切换是完全自动的。
****************************************************************************************************
*/
#include "ui.h"
#include "ui_common.h"
#include "app.h"
#include "ui_home.h"
#include "ui_player.h"
static app_mode_t s_last_mode = APP_MODE_NUM;
/**
* @brief 切换到某个 App 的界面
*/
static void ui_load_mode(app_mode_t mode)
{
switch (mode)
{
case APP_MODE_PLAYER:
ui_player_show();
break;
case APP_MODE_HOME:
default:
ui_home_show();
break;
}
}
/**
* @brief 把按键事件分发给当前界面
*/
static void ui_dispatch(const app_event_t *ev)
{
app_mode_t mode = app_get_mode();
/* ---------------- 1. K0:返回 / 切换应用(全局语义) ---------------- */
if (ev->key == APP_KEY_0)
{
/* 高亮交给当前界面,纯视觉效果 */
if (mode == APP_MODE_PLAYER)
{
ui_player_flash(ev->key);
}
else
{
ui_home_flash(ev->key);
}
if (ev->type == APP_KEY_LONG)
{
app_switch_next(); /* 长按:直接跳到下一个应用 */
}
else if (mode == APP_MODE_HOME)
{
app_switch_next(); /* 主界面短按:切换应用 */
}
else
{
app_set_mode(APP_MODE_HOME); /* App 内短按:返回主界面 */
}
return;
}
/* ---------------- 2. 其它按键的长按暂时没有功能 ---------------- */
if (ev->type == APP_KEY_LONG)
{
return;
}
/* ---------------- 3. 交给当前 App ---------------- */
switch (mode)
{
case APP_MODE_PLAYER:
ui_player_handle_key(ev->key);
break;
case APP_MODE_HOME:
default:
ui_home_handle_key(ev->key);
break;
}
}
/**
* @brief 界面周期任务(在 LVGL 任务中执行)
*/
static void ui_tick_cb(lv_timer_t *timer)
{
(void)timer;
app_mode_t mode = app_get_mode();
/* ---------------- 1. 应用切换:换屏 ---------------- */
if (mode != s_last_mode)
{
s_last_mode = mode;
ui_load_mode(mode);
}
/* ---------------- 2. 消费按键事件(唯一消费者) ---------------- */
app_event_t ev;
while (app_event_poll(&ev))
{
ui_dispatch(&ev);
}
/* ---------------- 3. 当前界面刷新 ---------------- */
switch (app_get_mode())
{
case APP_MODE_PLAYER:
ui_player_tick();
break;
case APP_MODE_HOME:
default:
ui_home_tick();
break;
}
}
void ui_create(void)
{
ui_home_create();
ui_player_create();
s_last_mode = app_get_mode();
ui_load_mode(s_last_mode);
lv_timer_create(ui_tick_cb, UI_TICK_MS, NULL);
}
+36
View File
@@ -0,0 +1,36 @@
/**
****************************************************************************************************
* @file ui.h
* @author HiFi Player Project
* @version V1.2
* @date 2026-01-01
* @brief 界面总入口(路由器)
*
* ui.c 只做两件事:
* 1. 按 app_get_mode() 切换到对应 App 的界面;
* 2. 作为“按键事件唯一消费者”,把事件分发给当前 App 的界面。
*
* 具体界面代码分别位于:
* home/ 主界面(应用启动器)
* player/ 播放器(界面 + 播放核心)
****************************************************************************************************
*/
#ifndef __UI_H
#define __UI_H
#ifdef __cplusplus
extern "C" {
#endif
/**
* @brief 创建全部界面并启动界面刷新定时器
* @note 调用前必须先执行 lvgl_port_init()
*/
void ui_create(void);
#ifdef __cplusplus
}
#endif
#endif /* __UI_H */
+163
View File
@@ -0,0 +1,163 @@
/**
****************************************************************************************************
* @file ui_common.c
* @author HiFi Player Project
* @version V1.2
* @date 2026-01-01
* @brief 界面共用组件实现
****************************************************************************************************
*/
#include "ui_common.h"
#include <stdio.h>
#include <string.h>
/* 底部按键提示条的布局参数 */
#define KEY_BAR_Y 196
#define KEY_BAR_W 74
#define KEY_BAR_H 40
#define KEY_BAR_GAP 4
#define KEY_BAR_X0 6
void ui_style_screen(lv_obj_t *scr)
{
lv_obj_clear_flag(scr, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_bg_color(scr, lv_color_hex(UI_COL_BG), LV_PART_MAIN);
lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_pad_all(scr, 0, LV_PART_MAIN);
lv_obj_set_style_text_color(scr, lv_color_hex(UI_COL_TEXT), LV_PART_MAIN);
}
lv_obj_t *ui_card(lv_obj_t *parent, lv_coord_t x, lv_coord_t y,
lv_coord_t w, lv_coord_t h, uint32_t color, lv_coord_t radius)
{
lv_obj_t *obj = lv_obj_create(parent);
lv_obj_set_pos(obj, x, y);
lv_obj_set_size(obj, w, h);
lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_bg_color(obj, lv_color_hex(color), LV_PART_MAIN);
lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_radius(obj, radius, LV_PART_MAIN);
lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN);
lv_obj_set_style_pad_all(obj, 0, LV_PART_MAIN);
return obj;
}
lv_obj_t *ui_label(lv_obj_t *parent, const char *text,
const lv_font_t *font, uint32_t color)
{
lv_obj_t *label = lv_label_create(parent);
lv_label_set_text(label, text);
lv_obj_set_style_text_font(label, font, LV_PART_MAIN);
lv_obj_set_style_text_color(label, lv_color_hex(color), LV_PART_MAIN);
return label;
}
void ui_label_set_if_changed(lv_obj_t *label, const char *text)
{
if (strcmp(lv_label_get_text(label), text) != 0)
{
lv_label_set_text(label, text);
}
}
void ui_format_time(uint32_t seconds, char *buffer, size_t size)
{
snprintf(buffer, size, "%02u:%02u",
(unsigned)((seconds / 60) % 100), (unsigned)(seconds % 60));
}
/* ---------------- 按键提示条 ---------------- */
void ui_key_bar_create(lv_obj_t *parent, ui_key_bar_t *bar,
const char *const symbols[APP_KEY_NUM],
const char *const captions[APP_KEY_NUM],
const bool enabled[APP_KEY_NUM])
{
memset(bar, 0, sizeof(*bar));
bar->highlight = -1;
for (int i = 0; i < APP_KEY_NUM; i++)
{
bool en = enabled[i];
bar->enabled[i] = en;
lv_obj_t *card = ui_card(parent,
(lv_coord_t)(KEY_BAR_X0 + i * (KEY_BAR_W + KEY_BAR_GAP)),
KEY_BAR_Y, KEY_BAR_W, KEY_BAR_H, UI_COL_CARD, 8);
bar->card[i] = card;
bar->icon[i] = ui_label(card, (symbols != NULL) ? symbols[i] : "",
&lv_font_montserrat_16, en ? UI_COL_ACCENT : UI_COL_DIM);
lv_obj_align(bar->icon[i], LV_ALIGN_TOP_MID, 0, 2);
bar->caption[i] = ui_label(card, captions[i], &lv_font_montserrat_12,
en ? UI_COL_SUB : UI_COL_DIM);
lv_obj_align(bar->caption[i], LV_ALIGN_BOTTOM_MID, 0, -3);
}
}
void ui_key_bar_set_highlight(ui_key_bar_t *bar, int index)
{
if (bar->highlight == index)
{
return; /* 状态未变化,避免无谓重绘 */
}
bar->highlight = index;
for (int i = 0; i < APP_KEY_NUM; i++)
{
bool on = (i == index) && bar->enabled[i];
lv_obj_set_style_bg_color(bar->card[i], lv_color_hex(on ? UI_COL_ACCENT : UI_COL_CARD),
LV_PART_MAIN);
lv_obj_set_style_text_color(bar->icon[i],
lv_color_hex(bar->enabled[i] ? (on ? UI_COL_BG : UI_COL_ACCENT)
: UI_COL_DIM),
LV_PART_MAIN);
}
}
void ui_key_bar_set_icon(ui_key_bar_t *bar, int key, const char *symbol)
{
if ((key < 0) || (key >= APP_KEY_NUM) || (symbol == NULL))
{
return;
}
ui_label_set_if_changed(bar->icon[key], symbol);
}
void ui_key_bar_set_caption(ui_key_bar_t *bar, int key, const char *caption)
{
if ((key < 0) || (key >= APP_KEY_NUM) || (caption == NULL))
{
return;
}
ui_label_set_if_changed(bar->caption[key], caption);
}
/* ---------------- 按键高亮 ---------------- */
void ui_flash_trigger(ui_flash_t *flash, int key)
{
flash->key = key;
flash->tick = lv_tick_get();
}
int ui_flash_active(const ui_flash_t *flash)
{
if ((flash->key < 0) || (flash->key >= APP_KEY_NUM))
{
return -1;
}
return (lv_tick_elaps(flash->tick) < UI_KEY_FLASH_MS) ? (int)flash->key : -1;
}
+114
View File
@@ -0,0 +1,114 @@
/**
****************************************************************************************************
* @file ui_common.h
* @author HiFi Player Project
* @version V1.2
* @date 2026-01-01
* @brief 界面共用组件:配色、基础控件、底部按键提示条、按键高亮
*
* 所有 App 的界面都从这里取用公共资源,保证风格一致、代码不重复。
****************************************************************************************************
*/
#ifndef __UI_COMMON_H
#define __UI_COMMON_H
#include <stdbool.h>
#include <stdint.h>
#include "lvgl.h"
#include "app.h"
#ifdef __cplusplus
extern "C" {
#endif
/* ---------------- 屏幕 ---------------- */
#define UI_HOR_RES 320
#define UI_VER_RES 240
#define UI_TICK_MS 50 /* 界面刷新 / 事件处理周期 */
#define UI_KEY_FLASH_MS 400 /* 按键提示高亮持续时间 */
/* ---------------- 配色 ---------------- */
#define UI_COL_BG 0x0B0E17
#define UI_COL_PANEL 0x151B2B
#define UI_COL_CARD 0x1C2438
#define UI_COL_CARD_SEL 0x233A5C
#define UI_COL_TRACK 0x232C42
#define UI_COL_ACCENT 0x4FC3F7
#define UI_COL_ACCENT2 0x7C4DFF
#define UI_COL_TEXT 0xF2F5FF
#define UI_COL_SUB 0x8A93A8
#define UI_COL_DIM 0x5A6076
#define UI_COL_OK 0x3DDC84
#define UI_COL_WARN 0xFFB020
#define UI_COL_ERR 0xFF5C5C
/* ---------------- 基础控件 ---------------- */
/** 给屏幕设置统一的背景与内边距 */
void ui_style_screen(lv_obj_t *scr);
/** 创建一个无边框圆角容器 */
lv_obj_t *ui_card(lv_obj_t *parent, lv_coord_t x, lv_coord_t y,
lv_coord_t w, lv_coord_t h, uint32_t color, lv_coord_t radius);
/** 创建标签 */
lv_obj_t *ui_label(lv_obj_t *parent, const char *text,
const lv_font_t *font, uint32_t color);
/** 文本不同才更新,减少重绘 */
void ui_label_set_if_changed(lv_obj_t *label, const char *text);
/** 秒数 -> "mm:ss" */
void ui_format_time(uint32_t seconds, char *buffer, size_t size);
/* ---------------- 底部按键提示条 ---------------- */
typedef struct {
lv_obj_t *card[APP_KEY_NUM];
lv_obj_t *icon[APP_KEY_NUM];
lv_obj_t *caption[APP_KEY_NUM];
bool enabled[APP_KEY_NUM];
int highlight; /* 当前高亮按键,-1 表示无 */
} ui_key_bar_t;
/**
* @brief 创建 4 个按键提示卡片(横向一排,位于屏幕底部)
* @param symbols LV_SYMBOL_xxx 图标,可为 NULL
* @param captions 卡片底部说明文字
* @param enabled 该按键在当前界面是否可用(不可用显示为灰色)
*/
void ui_key_bar_create(lv_obj_t *parent, ui_key_bar_t *bar,
const char *const symbols[APP_KEY_NUM],
const char *const captions[APP_KEY_NUM],
const bool enabled[APP_KEY_NUM]);
/** 设置某个按键的高亮状态(-1 表示全部取消) */
void ui_key_bar_set_highlight(ui_key_bar_t *bar, int index);
/** 修改某个按键的图标(例如播放/暂停切换) */
void ui_key_bar_set_icon(ui_key_bar_t *bar, int key, const char *symbol);
/** 修改某个按键的说明文字 */
void ui_key_bar_set_caption(ui_key_bar_t *bar, int key, const char *caption);
/* ---------------- 按键高亮闪动 ---------------- */
typedef struct {
int32_t key; /* 上次触发的按键,-1 表示无 */
uint32_t tick; /* 触发时刻(lv_tick_get) */
} ui_flash_t;
/** 记录一次按键,用于 400ms 高亮 */
void ui_flash_trigger(ui_flash_t *flash, int key);
/** 返回当前应当高亮的按键(超时或未触发返回 -1) */
int ui_flash_active(const ui_flash_t *flash);
#ifdef __cplusplus
}
#endif
#endif /* __UI_COMMON_H */