IPOD Mini

by Arnov Sharma in Circuits > Apple

762 Views, 7 Favorites, 0 Comments

IPOD Mini

Making My Own iPod From Scratch
29.gif
26.gif
24.gif
30.gif
thumbb.jpg

Greetings Everyone and Welcome Back.

Recently, there’s been a growing interest in going back to offline music players like the iPod and Zune. I wanted to build something similar but with a modern twist.

At the heart of this project is the Waveshare ESP32-P4 development board, which powers the entire device. I also designed the user interface from scratch, taking inspiration from the classic iPod Nano 6th Generation UI.

The device is primarily touchscreen-based, but instead of using a traditional physical click wheel, I created a software-based digital scroll wheel. It works similarly to the original iPod scroll wheel, allowing you to navigate through menus and control different functions.

The interface includes a Music section for playing songs, a Video section for video playback, and a Photos section for viewing images. I also added a dedicated Volume Control interface, allowing the scroll wheel to be used to increase or decrease the volume.

All of the media is stored on an SD card, which supports MP3, wav, flac, m4a, and several other common audio and video formats. The SD card can be accessed from the side of the enclosure through a dedicated opening, making it easy to add or replace media.

The goal of this project was to recreate the feel of a classic iPod while combining it with modern hardware and a touchscreen interface, and this article covers the complete build process of this project, let's get started.

Supplies

These were the materials used in this project

  1. Wavshare ESP32 P4 Dev board
  2. Speaker Module
  3. 3.7V LiPO Cell 800mAh
  4. SD Card with Legally downloaded music media (LOL)
  5. M2 screws
  6. 3D Printed parts

IPOD CLASSIC 6th GEN

s-l1200.jpg
images (32).jpg

The iPod Classic 6th Generation has always been one of those devices I wanted to own.

When I was younger, I never really had the opportunity to buy one. The iPods I came across were mostly cheap knockoffs, so the idea of owning a proper iPod Classic always felt a little out of reach. Even today, finding a good-condition original unit in India can be surprisingly expensive.

The 6th-generation iPod Classic was introduced by Apple in 2007, and it was a pretty impressive device for its time. It came with either 80 GB or 160 GB of storage, a 2.5-inch 320 × 240 color display, the iconic Click Wheel, and a battery rated for up to 30 hours of music playback on the 80 GB model and up to 40 hours on the 160 GB model.

The 160 GB version could hold up to 40,000 songs or 200 hours of video, which was pretty incredible for a pocket-sized device in 2007. It also supported formats such as MP3, AAC, Apple Lossless, WAV, and AIFF, along with video playback.

What I particularly like about the 6th generation is its design. It moved to an anodized aluminum front with a polished stainless-steel back, giving it that distinctive premium look, while retaining the Click Wheel that made navigating an iPod so satisfying.

Unfortunately, these devices are no longer produced, and finding one in good condition can be quite expensive. There is also a second option that I’ve been wanting to explore someday: flea markets and old electronics markets, where you can sometimes come across older iPods and other forgotten tech.

But for this project, instead of spending a significant amount of money hunting down an original, I decided to take a different route.

If I couldn't find the iPod I wanted, why not build one myself?

HARDWARE- ESP32-P4-WIFI6 4.3inch DEV BOARD

05.gif

For the main hardware, or the Brain of this project, I chose the Waveshare ESP32-P4-WIFI6-Touch-LCD-4.3 development board.

I’ve actually used this same development board in one of my previous PIPBOY projects, so I was already familiar with the hardware and its capabilities. When I started working on this iPod project, it felt like a natural choice, especially because of the combination of processing power, display, and touch interface it offers.

One of the main reasons I chose this board was its built-in 4.3-inch 480 × 800 capacitive touchscreen. The size and resolution worked really well for the iPod-style interface I had in mind, while having the display and touch hardware integrated into the development board significantly simplified the overall design.

The other major reason was the ESP32-P4 itself. It features a dual-core RISC-V processor running at up to 400 MHz, along with dedicated multimedia hardware and a much more capable memory and peripheral system compared to the ESP32 boards I’ve worked with.

That extra processing power was particularly useful for this project because I wanted the iPod to handle a graphical user interface, animations, touchscreen interaction, audio playback, and even video playback.

The board also includes an ESP32-C6 wireless co-processor, providing Wi-Fi 6 and Bluetooth 5 capabilities, which gives the project plenty of room for additional functionality.

https://docs.waveshare.com/ESP32-P4-WIFI6-Touch-LCD-4.3

Another thing that made working with this board easier was the documentation. Waveshare provides a dedicated Wiki with hardware information, examples, and development resources. I relied on this documentation quite a bit during development, particularly while working with the display, touchscreen, SD card, audio, and other peripherals.

Since I had already worked with this board on my Pip-Boy project, I was also able to get up and running with the hardware relatively quickly for this build.

WAVSHARE SERVICE

Screenshot 2026-09-04 204600.png
Screenshot 2026-09-04 205123.png

Special thanks to Waveshare for providing the hardware used in this project. The ESP32 P4 4.3-inch Dev Board and supporting accessories were supplied as review units for testing and evaluation.

Waveshare is a leading global provider of electronic components, modules, and development tools used across robotics, IoT, automation, education, and many other fields. With a strong focus on quality, reliability, and continuous innovation, Waveshare has earned the trust of engineers, designers, hobbyists, and makers worldwide.

Their extensive product lineup, from displays and HATs to expansion boards and embedded modules, makes them a go-to choice for both professional builds and DIY projects.

SPEAKER & SD CARD ASSEMBLY

06.gif
07.gif

For audio output, I used the 4Ω mini speaker that came included with the Waveshare ESP32-P4 development kit. Connecting it was quite straightforward; the speaker has a JST connector, which plugs directly into the corresponding JST connector on the Waveshare development board.

For storing the music and other media, I used the SD card slot built into the development board.

Our iPod Mini supports a range of common media formats:

  1. Audio: .mp3, .wav, .flac, .m4a, and .aac, with ID3 metadata used for filename cleanup and better organization.
  2. Photos: .jpg, .jpeg, .png, and .bmp. Images are decoded on the fly using the LVGL TJPGD and LodePNG decoders.
  3. Videos: .mjpeg and .avi. Video frames are streamed directly into PSRAM for playback.

CODE & DEMO RUN

DIY IPOD WITH ESP32 P4

Here's the main code used in this project and it's a simple one; i have broken it down into small files.

System 'main.c'

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "nvs_flash.h"
#include "bsp_init.h"
#include "audio_player.h"
#include "ui_main.h"
#include "media_scanner.h"
static const char *TAG = "MAIN";
void app_main(void)
{
ESP_LOGI(TAG, "Starting iPod Media Player Firmware on ESP32-P4...");
// 1. Initialize NVS (Non-Volatile Storage)
esp_err_t ret = nvs_flash_init();
if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
ret = nvs_flash_init();
}
ESP_ERROR_CHECK(ret);
// 2. Initialize Waveshare Hardware (Display, Touch, SD Card, I2S Audio)
ESP_ERROR_CHECK(app_bsp_init());
// 3. Initialize Audio Pipeline & Spawn Audio Decode Task on Core 1
ESP_ERROR_CHECK(audio_player_init());
// 4. Scan SD card for songs, photos, and videos
media_scanner_scan("/sdcard");
// 5. Initialize LVGL 9 User Interface on Core 0
ui_init();
ESP_LOGI(TAG, "System startup complete. Core 0 -> UI, Core 1 -> Audio.");
// 6. Periodic UI state synchronization loop (runs every 100ms)
while (1) {
ui_update_loop();
vTaskDelay(pdMS_TO_TICKS(100));
}
}

Click Wheel 'ui_click_wheel.c'

#include "ui_click_wheel.h"
#include <math.h>
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
// Click wheel geometry centered at (240, 600) on the 480x800 screen
#define WHEEL_CENTER_X 240.0f
#define WHEEL_CENTER_Y 600.0f // Center of the bottom 400px area
#define WHEEL_R_OUTER 140 // 280px diameter outer ring
#define WHEEL_R_INNER 48 // 96px diameter center button
#define SCROLL_STEP_DEG 16.0f // Degrees of rotation per scroll tick
static wheel_scroll_cb_t s_scroll_cb = NULL;
static wheel_click_cb_t s_click_cb = NULL;
// Gesture state variables
static bool s_is_pressed = false;
static bool s_is_swirling = false;
static float s_press_dist = 0.0f;
static float s_press_angle = 0.0f;
static float s_last_angle = 0.0f;
static float s_accum_scroll_deg = 0.0f;
static float s_total_deg_travel = 0.0f;
/*
* Unified Polar Touch Handler: Detects both directional button taps and rotary scrolling
*/
static void wheel_touch_event_cb(lv_event_t *e)
{
lv_event_code_t code = lv_event_get_code(e);
lv_indev_t *indev = lv_event_get_indev(e);
if (!indev) indev = lv_indev_active();
if (!indev) return;
lv_point_t pt;
lv_indev_get_point(indev, &pt);
// 1. Convert Cartesian (X, Y) touch coordinates to Polar (Radius, Angle)
float dx = (float)pt.x - WHEEL_CENTER_X;
float dy = (float)pt.y - WHEEL_CENTER_Y;
float dist = sqrtf(dx * dx + dy * dy);
float angle_deg = atan2f(dy, dx) * 180.0f / (float)M_PI; // Range: -180 to +180 deg
// ── Touch Down ──
if (code == LV_EVENT_PRESSED) {
s_is_pressed = true;
s_is_swirling = false;
s_press_dist = dist;
s_press_angle = angle_deg;
s_last_angle = angle_deg;
s_accum_scroll_deg = 0.0f;
s_total_deg_travel = 0.0f;
}
// ── Rotary Drag / Swirl ──
else if (code == LV_EVENT_PRESSING) {
if (!s_is_pressed) return;
// Check if finger is within the outer wheel ring (40px to 170px radius)
if (dist >= 40.0f && dist <= 170.0f) {
float diff = angle_deg - s_last_angle;
// Handle angle wraparound across -180 / +180 boundary
while (diff > 180.0f) diff -= 360.0f;
while (diff < -180.0f) diff += 360.0f;
s_total_deg_travel += fabsf(diff);
s_last_angle = angle_deg;
// Once user rotates > 12 degrees, lock into rotary scrolling mode
if (s_total_deg_travel >= 12.0f) {
s_is_swirling = true;
s_accum_scroll_deg += diff;
// Clockwise rotation (Scroll Down / Forward)
while (s_accum_scroll_deg >= SCROLL_STEP_DEG) {
if (s_scroll_cb) s_scroll_cb(+1);
s_accum_scroll_deg -= SCROLL_STEP_DEG;
}
// Counter-Clockwise rotation (Scroll Up / Backward)
while (s_accum_scroll_deg <= -SCROLL_STEP_DEG) {
if (s_scroll_cb) s_scroll_cb(-1);
s_accum_scroll_deg += SCROLL_STEP_DEG;
}
}
}
}
// ── Touch Released (Button Tap Trigger) ──
else if (code == LV_EVENT_RELEASED || code == LV_EVENT_PRESS_LOST) {
if (s_is_pressed && !s_is_swirling) {
if (s_press_dist < 50.0f) {
// Center Select Button Tap
if (s_click_cb) s_click_cb(WHEEL_BTN_CENTER);
} else if (s_press_dist >= 50.0f && s_press_dist <= 175.0f) {
// Quadrant Taps determined by touch angle
if (s_press_angle >= -135.0f && s_press_angle < -45.0f) {
if (s_click_cb) s_click_cb(WHEEL_BTN_MENU); // Top: MENU
} else if (s_press_angle >= 45.0f && s_press_angle < 135.0f) {
if (s_click_cb) s_click_cb(WHEEL_BTN_PLAY_PAUSE); // Bottom: PLAY/PAUSE
} else if (s_press_angle < -135.0f || s_press_angle >= 135.0f) {
if (s_click_cb) s_click_cb(WHEEL_BTN_PREV); // Left: PREV
} else {
if (s_click_cb) s_click_cb(WHEEL_BTN_NEXT); // Right: NEXT
}
}
}
s_is_pressed = false;
s_is_swirling = false;
}
}
/*
* Create the Click Wheel visual elements and register the event callback
*/
lv_obj_t *ui_click_wheel_create(lv_obj_t *parent, wheel_scroll_cb_t scroll_cb, wheel_click_cb_t click_cb)
{
s_scroll_cb = scroll_cb;
s_click_cb = click_cb;
// 1. Bottom Chassis (480x400)
lv_obj_t *wheel_cont = lv_obj_create(parent);
lv_obj_remove_style_all(wheel_cont);
lv_obj_set_size(wheel_cont, 480, 400);
lv_obj_align(wheel_cont, LV_ALIGN_TOP_LEFT, 0, 400);
lv_obj_set_style_bg_color(wheel_cont, lv_color_hex(0xEBEBEB), 0);
lv_obj_set_style_bg_opa(wheel_cont, LV_OPA_COVER, 0);
lv_obj_add_flag(wheel_cont, LV_OBJ_FLAG_CLICKABLE);
// 2. Outer White Ring Disc (280x280)
lv_obj_t *wheel_outer = lv_obj_create(wheel_cont);
lv_obj_remove_style_all(wheel_outer);
lv_obj_set_size(wheel_outer, WHEEL_R_OUTER * 2, WHEEL_R_OUTER * 2);
lv_obj_align(wheel_outer, LV_ALIGN_CENTER, 0, 0);
lv_obj_set_style_radius(wheel_outer, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_bg_color(wheel_outer, lv_color_hex(0xFFFFFF), 0);
lv_obj_set_style_bg_opa(wheel_outer, LV_OPA_COVER, 0);
// 3. Inner Silver Center Button (96x96)
lv_obj_t *center_btn = lv_obj_create(wheel_outer);
lv_obj_remove_style_all(center_btn);
lv_obj_set_size(center_btn, WHEEL_R_INNER * 2, WHEEL_R_INNER * 2);
lv_obj_align(center_btn, LV_ALIGN_CENTER, 0, 0);
lv_obj_set_style_radius(center_btn, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_bg_color(center_btn, lv_color_hex(0xD6D6D8), 0);
lv_obj_set_style_bg_opa(center_btn, LV_OPA_COVER, 0);
// 4. Quadrant Silk-Screen Labels
lv_obj_t *lbl_menu = lv_label_create(wheel_outer);
lv_label_set_text(lbl_menu, "MENU");
lv_obj_align(lbl_menu, LV_ALIGN_TOP_MID, 0, 20);
lv_obj_t *lbl_play = lv_label_create(wheel_outer);
lv_label_set_text(lbl_play, LV_SYMBOL_PLAY " " LV_SYMBOL_PAUSE);
lv_obj_align(lbl_play, LV_ALIGN_BOTTOM_MID, 0, -20);
lv_obj_t *lbl_prev = lv_label_create(wheel_outer);
lv_label_set_text(lbl_prev, LV_SYMBOL_PREV);
lv_obj_align(lbl_prev, LV_ALIGN_LEFT_MID, 20, 0);
lv_obj_t *lbl_next = lv_label_create(wheel_outer);
lv_label_set_text(lbl_next, LV_SYMBOL_NEXT);
lv_obj_align(lbl_next, LV_ALIGN_RIGHT_MID, -20, 0);
// Register unified polar touch event callback
lv_obj_add_event_cb(wheel_cont, wheel_touch_event_cb, LV_EVENT_ALL, NULL);
return wheel_cont;
}

Split Screen Menu 'ui_library.c'

#include "ui_main.h"
#define LEFT_PANE_W 240
#define RIGHT_PANE_W 240
static lv_obj_t *s_left_list = NULL;
static lv_obj_t *s_pv_title = NULL;
static lv_obj_t *s_pv_sub = NULL;
static lv_obj_t *s_pv_note = NULL;
static int s_sel_idx = 0;
static int s_item_count = 0;
/*
* Sets the classic iPod blue gradient on active selection
*/
static void set_row_selected(lv_obj_t *row, bool selected)
{
if (!row) return;
lv_obj_t *lbl = lv_obj_get_child(row, 0);
lv_obj_t *chev = lv_obj_get_child(row, 1);
if (selected) {
lv_obj_set_style_bg_color(row, lv_color_hex(0x4FA5FF), 0); // Top blue
lv_obj_set_style_bg_grad_color(row, lv_color_hex(0x0066FF), 0); // Bottom blue
lv_obj_set_style_bg_grad_dir(row, LV_GRAD_DIR_VER, 0);
lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0);
if (lbl) lv_obj_set_style_text_color(lbl, lv_color_hex(0xFFFFFF), 0);
if (chev) lv_obj_set_style_text_color(chev, lv_color_hex(0xFFFFFF), 0);
} else {
lv_obj_set_style_bg_color(row, lv_color_hex(0xFFFFFF), 0);
lv_obj_set_style_bg_grad_dir(row, LV_GRAD_DIR_NONE, 0);
if (lbl) lv_obj_set_style_text_color(lbl, lv_color_hex(0x000000), 0);
if (chev) lv_obj_set_style_text_color(chev, lv_color_hex(0xAAAAAA), 0);
}
}
/*
* Creates the split menu structure (Left 240px: Menu List | Right 240px: Preview Pane)
*/
lv_obj_t *ui_menu_view_create(lv_obj_t *parent)
{
lv_obj_t *menu_root = lv_obj_create(parent);
lv_obj_remove_style_all(menu_root);
lv_obj_set_size(menu_root, 480, 372);
lv_obj_align(menu_root, LV_ALIGN_TOP_LEFT, 0, 28);
// 1. Left List Container
s_left_list = lv_obj_create(menu_root);
lv_obj_remove_style_all(s_left_list);
lv_obj_set_size(s_left_list, LEFT_PANE_W, 372);
lv_obj_align(s_left_list, LV_ALIGN_TOP_LEFT, 0, 0);
lv_obj_set_flex_flow(s_left_list, LV_FLEX_FLOW_COLUMN);
// 2. Center 1px Divider Line
lv_obj_t *div = lv_obj_create(menu_root);
lv_obj_remove_style_all(div);
lv_obj_set_size(div, 1, 372);
lv_obj_align(div, LV_ALIGN_TOP_LEFT, LEFT_PANE_W, 0);
lv_obj_set_style_bg_color(div, lv_color_hex(0xCCCCCC), 0);
lv_obj_set_style_bg_opa(div, LV_OPA_COVER, 0);
// 3. Right Preview Container
lv_obj_t *right_preview = lv_obj_create(menu_root);
lv_obj_remove_style_all(right_preview);
lv_obj_set_size(right_preview, RIGHT_PANE_W, 372);
lv_obj_align(right_preview, LV_ALIGN_TOP_LEFT, LEFT_PANE_W + 1, 0);
lv_obj_set_style_bg_color(right_preview, lv_color_hex(0xF0F0F0), 0);
lv_obj_set_style_bg_opa(right_preview, LV_OPA_COVER, 0);
// Preview Art Box
lv_obj_t *icon_box = lv_obj_create(right_preview);
lv_obj_remove_style_all(icon_box);
lv_obj_set_size(icon_box, 140, 140);
lv_obj_align(icon_box, LV_ALIGN_TOP_MID, 0, 48);
lv_obj_set_style_radius(icon_box, 6, 0);
lv_obj_set_style_bg_color(icon_box, lv_color_hex(0xC8C8C8), 0);
lv_obj_set_style_bg_opa(icon_box, LV_OPA_COVER, 0);
s_pv_note = lv_label_create(icon_box);
lv_label_set_text(s_pv_note, LV_SYMBOL_AUDIO);
lv_obj_center(s_pv_note);
s_pv_title = lv_label_create(right_preview);
lv_label_set_text(s_pv_title, "Music");
lv_obj_align(s_pv_title, LV_ALIGN_TOP_MID, 0, 204);
s_pv_sub = lv_label_create(right_preview);
lv_label_set_text(s_pv_sub, "iPod Library");
lv_obj_align(s_pv_sub, LV_ALIGN_TOP_MID, 0, 230);
return menu_root;
}
/*
* Scrolls the active menu item via Click Wheel rotary gesture
*/
void ui_menu_scroll(int direction)
{
if (s_item_count <= 0 || !s_left_list) return;
int new_idx = s_sel_idx + direction;
if (new_idx < 0) new_idx = 0;
if (new_idx >= s_item_count) new_idx = s_item_count - 1;
if (new_idx != s_sel_idx) {
lv_obj_t *old_row = lv_obj_get_child(s_left_list, s_sel_idx);
if (old_row) set_row_selected(old_row, false);
s_sel_idx = new_idx;
lv_obj_t *new_row = lv_obj_get_child(s_left_list, s_sel_idx);
if (new_row) {
set_row_selected(new_row, true);
lv_obj_scroll_to_view(new_row, LV_ANIM_ON);
}
}
}

Now Playing Screen 'ui_now_playing.c'

#include "ui_main.h"
static lv_obj_t *s_title_lbl = NULL;
static lv_obj_t *s_artist_lbl = NULL;
static lv_obj_t *s_album_lbl = NULL;
static lv_obj_t *s_scrub_bar = NULL;
static lv_obj_t *s_time_cur = NULL;
static lv_obj_t *s_time_total = NULL;
static void fmt_time(char *buf, size_t sz, uint32_t sec)
{
snprintf(buf, sz, "%u:%02u", (unsigned int)(sec / 60), (unsigned int)(sec % 60));
}
/*
* Builds the Now Playing screen (Album Art, Track Title Scroller, Scrub Bar)
*/
lv_obj_t *ui_now_playing_view_create(lv_obj_t *parent)
{
lv_obj_t *np_root = lv_obj_create(parent);
lv_obj_remove_style_all(np_root);
lv_obj_set_size(np_root, 480, 372);
lv_obj_align(np_root, LV_ALIGN_TOP_LEFT, 0, 28);
lv_obj_set_style_bg_color(np_root, lv_color_hex(0xFFFFFF), 0);
lv_obj_set_style_bg_opa(np_root, LV_OPA_COVER, 0);
// 1. Album Art Tile (140x140 square)
lv_obj_t *art_box = lv_obj_create(np_root);
lv_obj_remove_style_all(art_box);
lv_obj_set_size(art_box, 140, 140);
lv_obj_align(art_box, LV_ALIGN_TOP_MID, 0, 16);
lv_obj_set_style_radius(art_box, 6, 0);
lv_obj_set_style_bg_color(art_box, lv_color_hex(0xC8C8C8), 0);
lv_obj_set_style_bg_opa(art_box, LV_OPA_COVER, 0);
lv_obj_t *art_icon = lv_label_create(art_box);
lv_label_set_text(art_icon, LV_SYMBOL_AUDIO);
lv_obj_center(art_icon);
// 2. Track Title (Auto circular scroll for long titles)
s_title_lbl = lv_label_create(np_root);
lv_label_set_text(s_title_lbl, "Track Title");
lv_obj_set_width(s_title_lbl, 440);
lv_label_set_long_mode(s_title_lbl, LV_LABEL_LONG_SCROLL_CIRCULAR);
lv_obj_set_style_text_align(s_title_lbl, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_align(s_title_lbl, LV_ALIGN_TOP_MID, 0, 172);
// 3. Artist & Album Labels
s_artist_lbl = lv_label_create(np_root);
lv_label_set_text(s_artist_lbl, "Artist Name");
lv_obj_align(s_artist_lbl, LV_ALIGN_TOP_MID, 0, 198);
s_album_lbl = lv_label_create(np_root);
lv_label_set_text(s_album_lbl, "Album Title");
lv_obj_align(s_album_lbl, LV_ALIGN_TOP_MID, 0, 220);
// 4. Scrub Bar & Timestamps
s_time_cur = lv_label_create(np_root);
lv_label_set_text(s_time_cur, "0:00");
lv_obj_align(s_time_cur, LV_ALIGN_TOP_LEFT, 24, 265);
s_time_total = lv_label_create(np_root);
lv_label_set_text(s_time_total, "0:00");
lv_obj_align(s_time_total, LV_ALIGN_TOP_RIGHT, -24, 265);
s_scrub_bar = lv_slider_create(np_root);
lv_obj_set_size(s_scrub_bar, 340, 6);
lv_obj_align(s_scrub_bar, LV_ALIGN_TOP_MID, 0, 269);
lv_slider_set_range(s_scrub_bar, 0, 100);
return np_root;
}
/*
* Refreshes track metadata and progress indicators
*/
void ui_now_playing_update(const ipod_playback_state_t *st)
{
if (!st || !s_title_lbl) return;
lv_label_set_text(s_title_lbl, st->title[0] ? st->title : "Not Playing");
lv_label_set_text(s_artist_lbl, st->artist[0] ? st->artist : "iPod Library");
lv_label_set_text(s_album_lbl, st->album[0] ? st->album : "Audio Track");
char buf[16];
fmt_time(buf, sizeof(buf), st->position_sec);
lv_label_set_text(s_time_cur, buf);
fmt_time(buf, sizeof(buf), st->duration_sec);
lv_label_set_text(s_time_total, buf);
if (st->duration_sec > 0 && s_scrub_bar) {
int32_t pct = (int32_t)(st->position_sec * 100 / st->duration_sec);
lv_slider_set_value(s_scrub_bar, pct, LV_ANIM_ON);
}
}

Main UI Orchestrator 'ui_main.c'

#include "ui_main.h"
#include "esp_lv_adapter.h"
static lv_obj_t *s_menu_view = NULL;
static lv_obj_t *s_np_view = NULL;
static lv_obj_t *s_sb_title = NULL;
static lv_obj_t *s_sb_play_icon = NULL;
static ipod_view_t s_current_view = VIEW_MENU;
/*
* Switches active visible screen (Split Menu <-> Now Playing)
*/
void ui_set_view(ipod_view_t view)
{
s_current_view = view;
if (view == VIEW_MENU) {
lv_obj_remove_flag(s_menu_view, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(s_np_view, LV_OBJ_FLAG_HIDDEN);
lv_label_set_text(s_sb_title, "iPod");
} else if (view == VIEW_NOW_PLAYING) {
lv_obj_add_flag(s_menu_view, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_flag(s_np_view, LV_OBJ_FLAG_HIDDEN);
lv_label_set_text(s_sb_title, "Now Playing");
}
}
/*
* Click Wheel button dispatcher
*/
static void on_wheel_click(wheel_button_t btn)
{
switch (btn) {
case WHEEL_BTN_MENU:
if (s_current_view == VIEW_NOW_PLAYING) {
ui_set_view(VIEW_MENU);
} else {
ui_menu_back();
}
break;
case WHEEL_BTN_CENTER:
if (s_current_view == VIEW_MENU) {
ui_menu_select();
}
break;
case WHEEL_BTN_PLAY_PAUSE:
audio_player_toggle_play();
break;
case WHEEL_BTN_PREV:
ui_library_play_prev();
break;
case WHEEL_BTN_NEXT:
ui_library_play_next();
break;
}
}
/*
* Master UI Initialization
*/
void ui_init(void)
{
esp_lv_adapter_lock(-1);
lv_obj_t *scr = lv_scr_act();
lv_obj_remove_style_all(scr);
lv_obj_set_size(scr, 480, 800);
// 1. Top Display Screen (480x400)
lv_obj_t *disp_cont = lv_obj_create(scr);
lv_obj_remove_style_all(disp_cont);
lv_obj_set_size(disp_cont, 480, 400);
lv_obj_align(disp_cont, LV_ALIGN_TOP_LEFT, 0, 0);
// Status bar (28px height)
lv_obj_t *sb_bar = lv_obj_create(disp_cont);
lv_obj_remove_style_all(sb_bar);
lv_obj_set_size(sb_bar, 480, 28);
lv_obj_set_style_bg_color(sb_bar, lv_color_hex(0xF2F2F2), 0);
lv_obj_set_style_bg_opa(sb_bar, LV_OPA_COVER, 0);
s_sb_play_icon = lv_label_create(sb_bar);
lv_obj_align(s_sb_play_icon, LV_ALIGN_LEFT_MID, 10, 0);
lv_label_set_text(s_sb_play_icon, "");
s_sb_title = lv_label_create(sb_bar);
lv_label_set_text(s_sb_title, "iPod");
lv_obj_align(s_sb_title, LV_ALIGN_CENTER, 0, 0);
// Create sub-views
s_menu_view = ui_menu_view_create(disp_cont);
s_np_view = ui_now_playing_view_create(disp_cont);
lv_obj_add_flag(s_np_view, LV_OBJ_FLAG_HIDDEN);
// 2. Bottom Click Wheel (480x400)
ui_click_wheel_create(scr, ui_menu_scroll, on_wheel_click);
esp_lv_adapter_unlock();
}
/*
* Called from main task loop every 100ms
*/
void ui_update_loop(void)
{
audio_state_t state;
if (audio_player_get_state(&state)) {
esp_lv_adapter_lock(-1);
// Update status bar play icon
if (state.is_playing) {
lv_label_set_text(s_sb_play_icon, LV_SYMBOL_PLAY);
} else {
lv_label_set_text(s_sb_play_icon, LV_SYMBOL_PAUSE);
}
// Update Now Playing screen if currently visible
if (s_current_view == VIEW_NOW_PLAYING) {
ui_now_playing_update(&g_ipod_state);
}
esp_lv_adapter_unlock();
}
}


LAYOUT OF IPOD UI

┌──────────────────────────────────────────────┐
│ [▶] iPod [▮▮▮] │ Status Bar (28px)
├──────────────────────┬───────────────────────┤
│ Music ▶ │ │
│ Videos ▶ │ ┌─────────┐ │
│ Photos ▶ │ │ │ │
│ Extras ▶ │ └─────────┘ │ Top Display (400px)
│ Settings ▶ │ Music │ - Split Menu View
│ Shuffle Songs │ 128 Tracks │ - Now Playing Screen
│ Now Playing ▶ │ │ - Photo & Video Player
├──────────────────────┴───────────────────────┤ (y = 400px)
│ │
│ MENU │
│ │
│ ┌──────────────────┐ │
│ |<< │ CENTER │ >>| │ Bottom Click Wheel (400px)
│ │ SELECT │ │ - Polar Touch Detection
│ └──────────────────┘ │ - Rotary Swirl Gestures
│ │
│ ▶ || │
│ │
└──────────────────────────────────────────────┘


Top Screen

  1. The status bar 28px displays play/pause status indicators and the current view title
  2. content viewpoint 372px houses the split menu; let 240px item list with blue gradient highlights right 240px live preview panel now playing cover art, scrub bar, fullscreen photo view or video canvas

Bottom Screen

  1. dedicated virtual iPod click wheel chassis with a 280px outer white disc and 96px inner silver select button


Virtual Click Wheel (Polar Mathematics)

Real iPod click wheels used capacitive rotary sensing rings. We simulate this on a standard capacitive touch screen using real-time polar coordinate mathematics.

the center of the virtual wheel is fixed at (x_c, y_c) = (240, 600). When a touch event is captured at a point (x, y), the firmware computes:

$$\Delta x = x - 240, \quad \Delta y = y - 600$$
$$\text{Radius } R = \sqrt{\Delta x^2 + \Delta y^2}$$
$$\text{Angle } \theta = \text{atan2}(\Delta y, \Delta x) \times \frac{180}{\pi} \quad [-180^\circ \dots +180^\circ]$$

It will look & work like this:

-90° (MENU)
▲
│
-135° ┌─────────┼─────────┐ -45°
│ \ TOP / │
│ \ / │
│ ┌───────┐ │
180° (PREV)│ LEFT│CENTER │RIGHT│ 0° (NEXT)
│ └───────┘ │
│ / \ │
│ / BOTTOM \ │
+135° └─────────┼─────────┘ +45°
│
▼
+90° (PLAY/PAUSE)


Dual Core Execution Pipeline

to guarantee stutter-free 60 fps graphics alongside lossless audio decoding, the firmware leverages FreeRTOS dual-core task pinning:

ESP32-P4 Core Distribution
┌───────────────────────────────────┐ ┌───────────────────────────────────┐
│ CORE 0: UI & Graphics │ │ CORE 1: Audio / Media │
│ ┌─────────────────────────────┐ │ │ ┌─────────────────────────────┐ │
│ │ LVGL 9 GUI Loop │ │ │ │ minimp3_ex Task │ │
│ │ (Triple-buffered MIPI-DSI) │ │ │ │ (Priority 10 FreeRTOS) │ │
│ └──────────────┬──────────────┘ │ │ └──────────────┬──────────────┘ │
│ │ │ │ │ │
│ ┌──────────────▼──────────────┐ │ │ ┌──────────────▼──────────────┐ │
│ │ Polar Click Wheel Driver │ │ │ │ I2S DMA Ring Buffer │ │
│ │ GT911 Capacitive Touch │ │ │ │ to ES8311 Codec │ │
│ └─────────────────────────────┘ │ │ └─────────────────────────────┘ │
└─────────────────┬─────────────────┘ └─────────────────┬─────────────────┘
│ │
══════════════════╪════════════════════════════════════════╪══════════════════
▼ ▼
480x800 ST7701 MIPI-DSI ES8311 I2S 16-bit 44.1kHz


Core 0 (UI & Display)

  1. runs the LVGL 9 display driver with triple partial rendering buffers.
  2. manages touchscreen input parsing and visual animations.
  3. synchronizes UI playback timers with thread-safe locks ('esp_lv_adapter_lock').

Core 1 (Audio Pipeline)

  1. high-priority RTOS task streaming MP/Wav/FLAC data from SDMMC.
  2. decodes audio chunks via 'minimp3_ex' directly into the DMA ring buffer feeding the ES8311 codec over I2S.


Media Scanner & Supported Formats

When inserted, the firmware recursively searches '/shcard' up to 4 directories deep and indexes media:

  1. Audio '.mp3' , '.wav' , '.flac' , '.m4a' , '.aac' . With ID3 Filename metadata cleanup
  2. Photos '.jpg' , '.jpeg' , '.png' , '.bmp' . Decoded on the fly via LVGL TJPGD and LodePNG decoders.
  3. Videos '.mjpeg' , '.avi' . Stremed frame by frame directly into PSRAM


Step-by-Step Ui Design

recreating the authentic iPod classic user experience required combining vintage design aesthetics with modern embedded GUI engineering. Here is exactly how we structured, styled, and rendered the user interface using LVGL 9.

Authentic Color Palette

To faithfully mirror the 6th/7th generation iPod Classic, we defined a strict design system with curated hex colors and typography tokens:

#define IPOD_COL_BG lv_color_hex(0xFFFFFF) /* Clean white list background */
#define IPOD_COL_PREVIEW_BG lv_color_hex(0xF0F0F0) /* Subtle gray preview pane */
#define IPOD_COL_CHASSIS lv_color_hex(0xEBEBEB) /* Anodized aluminum body */
#define IPOD_COL_STATUSBAR lv_color_hex(0xF2F2F2) /* Top silver status bar */
#define IPOD_COL_DIVIDER lv_color_hex(0xCCCCCC) /* 1px column separator */
#define IPOD_COL_SEL_TOP lv_color_hex(0x4FA5FF) /* Glossy blue gradient top */
#define IPOD_COL_SEL_BOT lv_color_hex(0x0066FF) /* Glossy blue gradient bottom */
#define IPOD_COL_TEXT lv_color_hex(0x000000) /* High-contrast text */
#define IPOD_COL_TEXT_SUB lv_color_hex(0x888888) /* Secondary artist/album gray

LVGL Widget Tree & Screen Hierarchy

instead of creating and destroying screens dynamically, all ui views are pre-allocated once in PSRAM during start and placed inside the top 480x400 viewpoint. View transitions happen instantaneously with zero heap fragmentation simply by toggling the 'LV_OBJ_FLAG_HIDDEN' flag

lv_scr_act() (480x800 Base Screen)
├── s_display_cont (Top 480x400 Display Container)
│ ├── s_sb_bar (28px Top Status Bar)
│ │ ├── s_sb_play_icon (Left: Play/Pause indicator)
│ │ ├── s_sb_title (Center: "iPod" / "Now Playing")
│ │ └── s_sb_battery (Right: Battery level icon)
│ │
│ ├── s_menu_view (Split Menu View: 480x372)
│ │ ├── s_left_list (240px: Vertical flex item list)
│ │ ├── div (1px: Vertical divider line)
│ │ └── s_right_preview (240px: Live preview & album art)
│ │
│ ├── s_np_view (Now Playing View: 480x372)
│ │ ├── s_art_box (140x140 album artwork card with drop shadow)
│ │ ├── s_title_lbl (Marquee circular scrolling track title)
│ │ ├── s_artist_lbl & s_album_lbl
│ │ └── s_scrub_bar (Interactive progress slider & timestamps)
│ │
│ ├── s_photo_view (Full-screen Photo & Slideshow View)
│ └── s_video_view (480x360 MJPEG Video Canvas & Control Bar)
│
└── s_wheel_cont (Bottom 480x400 Virtual Click Wheel)
└── s_wheel_outer (280px white disc with concentric 96px center button)

Crafting the 2-column split menu

the signature iPod split-screen layout divides the display into two 240px columns

  1. The left pane uses 'LV_FLEX_FLOW_COLUMN' to hold selectable rows; each row contains a label and a right-aligned chevron. When selected, 'set_row_selected()' applies a vertical linear gradient 'LV_GRAD_DIR_VER' from '#4FA5FF' down to '#0066FF' and turns the text crisp white
  2. Right pane Updates synchronously as the user scrolls; when highlighting "Music," it displays a music note icon with total song counts; when hovering over individual songs, it displays the track album art and artist metadata

Designing the "Now Playing" Screen 'ui_now_playing.c'

  1. Album Art Tile a 140x140 square with a 6px border radius and a subtle drop shadow 'LV_OPA_45' offset Y = 4 to give physical depth
  2. Circular Marquee Title: Long song titles automatically scroll horizontally without clipping using 'LV_LABEL_LONG_SCROLL_CIRCULAR'
  3. Scrubber Bar built using an 'lv_slider' with an accent blue indicator bar flanked by current and remaining time labels

Building the Fullscreen Photo & Video Viewer

  1. Photo Viewer 'photo_viewer.c' uses LVGL's streaming TJPGD decoder to render high resolution JPEGs directly from the SD card a translucent dark header bar '#1A1A1A' at 'LV_OPA_200' displays the photo index and slideshow status
  2. Video Screen 'ui_video_screen.c' directly draws decoded MJPEG video frames onto a 480x360 PSRAM canvas while displaying a sleek bottom control bar with a live scrub slider

Layering the Virtual Click wheel 'ui_click_wheel.c'

the virtual click wheel is rendered using concentric circular widgets:

  1. Outer Disc: a 280x280 circular container 'LV_RADIUS_CIRCLE' in pure white '#FFFFFF' with a soft 10px drop shadow
  2. Inner Center Button a 96x96 circular silver disc '#D6D6D8' entered within the outer wheel
  3. Quadrant Labels placed precisely at top 'MENU' , bottom 'PLAY PAUSE' , left 'PREV' and right 'NEXT' using Montserrat fonts and iconic symbols
  4. Touch Interceptor a single unified event callback attached to the root chassis listen to 'LV_EVENT_ALL' , running the polar mathematics engine to detect taps and rotary swirling.

Build and Flash

Install ESP-IDF 'V5.3' or newer:

git clone --recursive https://github.com/espressif/esp-idf.git
cd esp-idf
./install.sh esp32p4
. ./export.sh

Clone the Repository

git clone https://github.com/waveshareteam/ESP32-P4-WIFI6-Touch-LCD-4.3.git
cd ESP32-P4-WIFI6-Touch-LCD-4.3/ipod_player

Build & Flash

set the target to ESP2-P4 and compile:

idf.py set-target esp32p4
idf.py build
idf.py -p /dev/ttyACM0 flash monitor

Prepare MicroSD Card

  1. Format A MicroSD card as 'FAT32' or 'exFat'
  2. Create directories and copy your favourite media:
`/Music/` $\rightarrow$ Copy your MP3 / WAV / FLAC files.
`/Photos/` $\rightarrow$ Copy your JPEG / PNG photos.
`/Videos/` $\rightarrow$ Copy your 480×360 MJPEG files.
  1. Insert the card into the Waveshare ESP32-P3 board and reboot.

DESIGN

untitled.875.png
untitled.879.png
untitled.878.png
untitled.880.png
untitled.876.png
Screenshot 2026-10-05 165814.png
Screenshot 2026-10-05 165959.png
untitled.877.png

For the enclosure design, my first hurdle was the display itself.

The classic iPod has a very distinctive form factor, with a relatively small display and the iconic scroll wheel positioned underneath it. The overall design is also quite boxy, although the newer generations gradually became thinner compared to the older, chunkier models.

Since I wanted to loosely base my design on the iPod, I decided to keep the enclosure simple and boxy as well. However, the thickness had to be increased to accommodate the Waveshare ESP32-P4 development board along with the LiPo battery inside.

I designed the enclosure as two separate halves, a front enclosure and a back enclosure. Both halves are joined together using four M2 screws, with mounting points placed along the top and bottom.

On the bottom, the ESP32-P4 board has two USB Type-C ports, so I designed a dedicated recessed area around them. This gives enough clearance to plug in a USB-C cable as well as USB-C earphones without having the enclosure get in the way.

I also wanted the SD card to remain accessible without having to open the entire enclosure. To achieve this, I created an opening along the right side, aligned with the SD card slot on the development board. This allows the SD card to be inserted or removed directly from the outside.

And honestly, looking at the final design, it probably resembles a Nokia Lumia more than an iPod. But I actually really like how it turned out. It feels fresh while still retaining some of the simple, boxy character I wanted—and more importantly, every part of the design has a functional purpose.

ASSEMBLY

Screenshot 2026-10-05 170056.png
Screenshot 2026-10-05 170408.png
Screenshot 2026-10-05 170432.png
Screenshot 2026-10-05 170506.png
Screenshot 2026-10-05 170859.png

As mentioned in the design section, the main body of the iPod consists of two separate enclosure halves, the front enclosure and the back enclosure.

The display is essentially sandwiched between these two parts. It sits in the dedicated mounting area of the front enclosure, while the back enclosure has integrated support ribs that hold the display securely in position and prevent it from moving around inside the enclosure.

The back enclosure also houses the 3.7V LiPo battery and the slide power switch. I added a dedicated opening on the top face of the enclosure for the slide switch.

To join both enclosure halves together, I added mounting holes on the top and bottom faces. These mounting points allow the front and back sections to be secured using four M2 screws, keeping the entire assembly firmly together.

3D PRINTED PARTS

01.gif
02.gif
03.gif
04.gif

After finalizing the 3D model, I exported the enclosure as an STL mesh file and prepared it for 3D printing.

For the first prototype, I printed both enclosure parts using Red Hyper PLA. The print quality came out really well, but after assembling the parts, I noticed a couple of fitment issues. The overall design also had some unexpected Nokia Lumia vibes, so I decided to make a few changes before producing the final version.

I slightly modified the enclosure by removing around 0.1 mm of material from the inner lip. This gave the two enclosure halves a much tighter and cleaner fit when assembled.

For the final print, I used a special tri-color PLA+ filament that I had. The filament contains three colors—copper, purple, and brown—and the visible color of the enclosure changes depending on the viewing angle, giving the finished device a really interesting appearance.

Since this was a PLA+ filament, I sliced the model using the regular PLA+ profile rather than the high-speed printing profile.

The final print settings were

  1. Material: Tri-color PLA+
  2. Layer Height: 0.16 mm
  3. Infill: 25%
  4. Top Z Distance: 0.3 mm
  5. Supports: Tree supports
  6. Print Profile: Regular PLA+ profile

The combination of the fine layer height and the color-shifting filament gave the enclosure a clean finish while adding a unique look to the final device.

Downloads

POWER SOURCE

08.gif

For the power source, I’m using a 3.7V 800mAh LiPo cell.

The main reason I chose this particular LiPo was its compact size. One of my main goals with this project was to keep the iPod as thin and compact as possible, so using a cylindrical 18650 cell wasn’t really practical for this enclosure.

The slim form factor of the LiPo cell allowed me to fit the battery inside the enclosure while keeping the overall thickness of the device relatively low.

BATTERY SETUP

09.gif
61ZATEc671L._AC_UF1000,1000_QL80_.jpg

For the battery setup, we need two additional components along with the LiPo cell: a small slide switch and a 2-wire battery connector harness compatible with the battery connector on the Waveshare ESP32-P4 board.

First, connect the positive and negative wires of the harness to the corresponding positive and negative terminals of the LiPo battery.

For the power switch, we place the slide switch in series with the positive wire. Connect the battery’s positive terminal to one terminal of the slide switch, and then connect the other terminal of the switch to the red positive wire of the battery harness.

The negative terminal of the battery can be connected directly to the negative wire of the harness.

This gives us a simple ON/OFF power switch, allowing us to disconnect the battery power from the ESP32-P4 board whenever the device is not in use.

BACK ENCLOSURE ASSEMBLY

10.gif
11.gif
12.gif
13.gif
  1. The back enclosure assembly begins by securing the LiPo battery in its designated mounting area. First, apply a piece of double-sided tape to the battery mounting position and remove its protective backing.
  2. Next, carefully place the LiPo cell into position and press it down firmly to ensure good adhesion.
  3. Just above the battery, position the slide power switch in its dedicated opening. The switch is already designed to be a pressure fit, but since it is a moving component that will be operated frequently, I also used hot glue to secure it in place.

This prevents the switch from shifting or coming loose during repeated use while still allowing the switch itself to move freely for turning the device ON and OFF.

FRONT ENCLOSURE ASSEMBLY

14.gif
15.gif
16.gif
  1. Next, place the Waveshare ESP32-P4 development board into its designated position inside the front enclosure.
  2. Once the board is properly aligned, use a small amount of hot glue to secure it in place. This keeps the board firmly seated inside the enclosure and prevents it from shifting during assembly and regular use.

FINAL ASSEMBLY

17.gif
18.gif
19.gif
21.gif
22.gif
23.gif
  1. For the final assembly, we first add a piece of double-sided tape to the designated speaker mounting area on the lower section of the back enclosure. Remove the protective backing and carefully place the speaker over the tape, making sure it is properly aligned and securely held in position.
  2. Next, connect the battery’s 2-pin JST connector to the corresponding battery connector on the Waveshare ESP32-P4 development board.
  3. Once all the internal components are connected and positioned correctly, bring the front and back enclosure halves together and carefully close the assembly.
  4. Finally, secure both halves permanently using four M2 screws—two from the top and two from the bottom of the enclosure.

and with that, the assembly process is completed.

RESULT

27.gif
28.gif
29.gif
30.gif
IMG_E4234.JPG

Here's the end result of this build, a fully functional DIY iPod Mini.

The music player can play common audio formats such as MP3, along with other supported audio files stored on the SD card. I also added a video playback mode, allowing the device to play video files such as MP4 and AVI. Along with music and videos, there is also a dedicated Photos section for storing and viewing images directly from the SD card.

For audio output, the device has a built-in speaker, and it gets surprisingly loud for something this compact. Since the ESP32-P4 board also provides USB connectivity, I can use USB Type-C headphones when I want a more private listening experience.

The 3.7V 800mAh LiPo battery gives me around one hour of backup, which I think is a reasonable compromise considering the compact size of the device, touchscreen, speaker, and relatively power-hungry multimedia features.

Overall, the project turned out pretty much the way I envisioned it—a compact offline media player with a custom iPod-inspired interface, digital scroll wheel, music, video and photo playback.

So yes, the DIY iPod Mini is a success!

OVERVIEW

26.gif
Making My Own iPod From Scratch - Part 2

Overall, I’m quite happy with how this project turned out. The hardware, enclosure, custom UI, digital scroll wheel, music playback, video playback, and photo viewer all work as intended, so there aren’t any major changes I feel are necessary.

The main thing I would like to improve is the battery life. At the moment, the 3.7V 800mAh LiPo gives me around one hour of runtime, so improving power efficiency or finding a better battery solution would be a priority for a future revision.

Another area I’d love to experiment with is the UI and software side. Since I’ve previously used this same hardware to build a Fallout-inspired Pip-Boy project with a completely custom Pip-Boy interface, I think a similar approach could work really well here. The iPod could have multiple selectable themes, such as a Fallout/Pip-Boy theme, a Cyberpunk-inspired interface, or even completely different custom UI designs.

And why stop there? Since the ESP32-P4 is capable of handling much more than just music playback, this device could potentially be turned into a small multimedia and gaming handheld. I could add some simple homebrew games or even experiment with Game Boy emulation. I’ve already worked with Game Boy emulation in one of my GBA projects, so bringing something similar to this device would be an interesting future upgrade.

For now, though, the core project is complete and working exactly as intended, and I’m really happy with the result.

All the details, Files, Code & wiring details are all attached in this article, so make sure to check them out.

Special thanks for reaching this far and i will be back with a new project real soon.

PEACE.