Home ▸ Arduino ▸ Displays & Interfaces ▸ MP3 Player using DFPlayer mini

Arduino MP3 Player with DFPlayer Mini and ILI9341 Touchscreen

An Arduino can play music with very little code when it is paired with a DFPlayer Mini, a small module that reads MP3 files from an SD card and drives a speaker directly. Adding an ILI9341 touchscreen turns the player into a complete device with a playlist, a track screen, playback buttons and a volume slider. The Arduino only sends short serial commands to the DFPlayer, so it has plenty of time left to draw the interface.

This is Part 2 of the ILI9341 series. Part 1, the ILI9341 touchscreen tutorial, covers the display wiring, level shifting, library setup and touch calibration, and this tutorial assumes that your display and touch panel already work. Here you will wire the DFPlayer Mini, prepare the SD card and test the module on its own. After that, you will build a playlist screen, a player screen with a progress bar, touch buttons for play, pause, next and previous, an equalizer and rotating disc animation and a volume slider. The full sketch at the end combines everything.

The project uses serial communication and the SPI display together, so the Arduino UART tutorial and the Arduino UNO pinout guide are useful references. The other display tutorials in the series are listed in the Arduino display tutorials.

Arduino MP3 Player Project Overview

The player has two screens. The playlist screen lists the tracks, and a touch on a row starts that track. The player screen shows the track title, a progress bar, an equalizer, a rotating disc, the playback buttons and a volume slider. Every touch updates the screen and sends a command to the DFPlayer Mini, and a simple timing loop drives the animations and the progress bar.

What You Will Learn

  • How to wire the DFPlayer Mini and the speaker to an Arduino.
  • How to prepare the SD card so that the module finds every track.
  • How to draw a playlist and a player screen on the ILI9341 display.
  • How to send play, pause, next, previous and volume commands to the DFPlayer.
  • How to animate a progress bar, an equalizer and a rotating disc without redrawing the whole screen.
  • How to detect taps and drags on the touch panel, so that one touch causes one action.

Hardware Required

  • An Arduino Uno, Nano or Mega
  • An ILI9341 TFT display with the XPT2046 touch controller, 240 × 320 pixels, wired as in Part 1
  • A DFPlayer Mini MP3 module
  • A microSD card with MP3 files
  • A small speaker of 1 W to 3 W and 8 Ω, or an amplifier
  • A resistor of 1 kΩ and a capacitor of 100 µF to 470 µF
  • Jumper wires and a stable 5 V supply

The sketch uses SoftwareSerial for the DFPlayer, so it runs on the Uno and the Nano. On a Mega, you can use a hardware serial port such as Serial1 instead. An ESP32 needs a different serial setup and is not covered here.


DFPlayer Mini Wiring

The DFPlayer Mini handles the MP3 decoding, the file system and the amplifier for a small speaker. It talks to the Arduino over a serial connection, and the display wiring from Part 1 stays unchanged.

DFPlayer Mini pinout diagram showing VCC, RX, TX, DAC_R, DAC_L, SPK_1, GND, SPK_2 and the SD card slot

DFPlayer Mini to Arduino Connections

DFPlayer Mini pinArduino pin or partDescription
VCC5VPower supply of the module
GNDGNDCommon ground
TXD4Serial data from the DFPlayer to the Arduino
RXD5 through a 1 kΩ resistorSerial data from the Arduino to the DFPlayer
SPK_1Speaker +Speaker output
SPK_2Speaker –Speaker output
Arduino Uno wired to a DFPlayer Mini and an 8 ohm speaker with TX on D4, RX on D5 through a 1 kΩ resistor and a capacitor on the supply

The SoftwareSerial library lets any two digital pins act as a serial port, so the pins D4 and D5 are a choice and not a requirement. The sketch assigns D4 as the receive pin of the Arduino and D5 as the transmit pin. The Arduino UART tutorial explains how serial communication works.


Wiring Notes

  1. Use a speaker of 1 W to 3 W and 8 Ω. For better sound, connect the DAC_L and DAC_R pins to an external amplifier or to a headphone jack.
  2. Place a resistor of 1 kΩ in series with the line from the Arduino TX pin to the DFPlayer RX pin. The DFPlayer uses 3.3 V logic, and the resistor reduces noise and protects the input.
  3. Do not connect SPK_2 to ground. The two speaker pins form a bridge output, and both must go to the speaker only.
  4. Power the module from 5 V and not from the 3.3 V pin of the Arduino, because that pin cannot supply the current that the module needs.
  5. Connect a capacitor of 100 µF to 470 µF across the VCC and GND pins of the DFPlayer, close to the module. It absorbs the current peaks of the speaker and prevents crackling.
  6. Make sure that the Arduino, the display and the DFPlayer share a common ground.

DFPlayer Mini SD Card Setup

The DFPlayer finds files by their names, so the card must be prepared correctly.

  1. Format the microSD card as FAT32. FAT16 also works, and cards up to 32 GB are the safest choice.
  2. Create a folder named MP3 in the root of the card.
  3. Copy the tracks into that folder and name them with four digits, as in 0001.mp3, 0002.mp3 and 0003.mp3. A name can continue after the four digits, for example 0001_Song.mp3.
  4. Insert the card into the slot of the DFPlayer before you power the module.
SD card root folder with an MP3 folder that contains the files 0001.mp3, 0002.mp3 and 0003.mp3 for the DFPlayer Mini

The sketch plays a track with playMp3Folder(n), which opens the file with the number n in the MP3 folder. This command works by file name, so it does not depend on the order in which the files were copied to the card. The play(n) command of the library uses the order of the files in the file system instead, and that order can differ from the numbering that you expect.


DFPlayer Mini Library and Test Sketch

The DFRobotDFPlayerMini library provides simple functions for play, pause, next, previous and volume.

Install the DFRobotDFPlayerMini Library

  1. Open the Arduino IDE and go to Sketch, then Include Library, then Manage Libraries.
  2. Search for “DFRobotDFPlayerMini” and install the library from DFRobot.
  3. Make sure that SoftwareSerial is available, which is the case in every standard Arduino IDE.
Arduino IDE Library Manager with the DFRobotDFPlayerMini library selected for installation

Include both libraries at the top of the sketch.

#include <SoftwareSerial.h>
#include "DFRobotDFPlayerMini.h"

Test the DFPlayer Mini on Its Own

Test the module before you add the display, so that you know the wiring and the card are correct. The following sketch starts the module, prints the number of files that it found and plays the first track.

#include <SoftwareSerial.h>
#include "DFRobotDFPlayerMini.h"

SoftwareSerial dfSerial(4, 5);   // RX = D4 (from DFPlayer TX), TX = D5 (to DFPlayer RX)
DFRobotDFPlayerMini myDFPlayer;

void setup() {
  Serial.begin(9600);
  dfSerial.begin(9600);
  delay(1000);                      // The module needs a moment to start

  if (!myDFPlayer.begin(dfSerial)) {
    Serial.println(F("DFPlayer error. Check the wiring and the SD card."));
    while (true);
  }

  Serial.println(F("DFPlayer OK"));
  Serial.print(F("Files on the card: "));
  Serial.println(myDFPlayer.readFileCounts());

  myDFPlayer.volume(15);            // 0 to 30
  myDFPlayer.playMp3Folder(1);      // Plays /MP3/0001.mp3
}

void loop() {
}

The first track should start playing, and the Serial Monitor should show “DFPlayer OK”. If the sketch reports an error, check the TX and RX wires, the card format and the power supply before you continue.


ILI9341 MP3 Playlist Screen

The playlist screen shows every track in a vertical list. Each row is 40 pixels high, and the rows alternate between two colors so that they are easy to tell apart. The sketch uses the display in portrait mode with a size of 240 by 320 pixels, and the calibrated touch panel from Part 1.

Draw the Track List

const char *trackList[] = {"Track 01", "Track 02", "Track 03", "Track 04", "Track 05"};
const int totalTracks = 5;

void drawPlaylistScreen() {
  tft.fillScreen(ILI9341_BLACK);

  tft.setTextSize(1);
  tft.setTextColor(ILI9341_WHITE);
  tft.setCursor(10, 16);
  tft.print("Tap a track to play");

  tft.setTextSize(2);
  for (int i = 0; i < totalTracks; i++) {
    int y = 40 + i * 40;
    uint16_t bg = (i % 2 == 0) ? ILI9341_DARKGREY : ILI9341_NAVY;
    tft.fillRect(0, y, SCR_W, 40, bg);
    tft.setCursor(10, y + 12);
    tft.print(trackList[i]);
  }
}

The track names are stored as const char * pointers and not as String objects. A String allocates memory on the heap, and the 2 KB of SRAM on the Uno fills up quickly when a sketch also holds a display library and a serial buffer.

ILI9341 playlist screen with five track rows in alternating dark grey and navy colors and a hint to tap a track

Select a Track by Touch

The touch handler converts the vertical position of the touch into a row number. Row 0 starts at y = 40, so the row is the offset from that start divided by the row height.

void handlePlaylistTouch(int ty) {
  if (ty < 40) return;

  int row = (ty - 40) / 40;
  if (row >= 0 && row < totalTracks) {
    playTrack(row);
    currentScreen = PLAYER;
    drawPlayerScreen();
  }
}

The function playTrack() stores the new track number, resets the elapsed time and sends the play command to the DFPlayer. The player screen then opens right away.

Animated capture of a track being touched on the playlist screen and the player screen opening with the track playing

ILI9341 MP3 Player Screen

The player screen is drawn once when it opens, and only the parts that change are redrawn after that. A full-screen redraw in every loop pass makes the display flicker, so the sketch updates small areas only.

Track Title Bar

The title bar is a dark strip at the top of the screen. It shows the name of the current track on the left and a menu button on the right that returns to the playlist.

tft.fillRect(0, 0, SCR_W, TITLE_H, ILI9341_DARKGREY);
tft.setTextSize(2);
tft.setTextColor(ILI9341_WHITE);
tft.setCursor(8, 9);
tft.print(trackList[currentTrack]);

The title does not scroll, which keeps the screen calm and avoids constant redraws.

ILI9341 player screen with the track title and a menu button in the top bar and a progress bar below it

Progress Bar

The DFPlayer Mini does not report the position of the track, so the sketch counts the seconds with millis() and moves the bar in proportion to the length of the track. The array trackSeconds stores the length of each track in seconds, and you should enter the real lengths of your files.

const int trackSeconds[] = {40, 40, 40, 40, 40};   // Length of each track in seconds

void updateProgressBar() {
  int w = constrain(map(elapsedTime, 0, trackSeconds[currentTrack], 0, BAR_W), 0, BAR_W);

  if (w > prevBarWidth) {
    // Green for the first 60 percent, orange up to 90 percent, then red
    uint16_t color = ILI9341_GREEN;
    if (elapsedTime * 10 >= trackSeconds[currentTrack] * 9)      color = ILI9341_RED;
    else if (elapsedTime * 10 >= trackSeconds[currentTrack] * 6) color = ILI9341_ORANGE;

    tft.fillRect(BAR_X + prevBarWidth, BAR_Y, w - prevBarWidth, BAR_H, color);
    prevBarWidth = w;
  }
}

The function draws only the new part of the bar, so the cost of each update is tiny. The loop counts the seconds and starts the next track when the time runs out.

if (isPlaying && (now - lastSec >= 1000)) {
  lastSec = now;
  elapsedTime++;
  if (elapsedTime > trackSeconds[currentTrack]) nextTrack();
}

Because the counter only runs while isPlaying is true, the bar stops when you pause the music.

Animated capture of the progress bar on the ILI9341 player screen filling from left to right and changing from green to orange

MP3 Player Touch Buttons

A music player needs four controls: play and pause in one button, next and previous. Each button is a rectangle with an icon, and a touch counts when it falls inside that rectangle.

Button Layout

The sketch stores each button as a Rect with a position and a size, and the same helper tests every touch.

struct Rect { int x, y, w, h; };

const Rect MENU_BTN = {204, 2, 32, 28};
const Rect PREV_BTN = {10, 200, 60, 48};
const Rect PLAY_BTN = {96, 200, 48, 48};
const Rect NEXT_BTN = {170, 200, 60, 48};
const Rect VOL_BAR  = {20, 270, 200, 12};

bool inside(const Rect &r, int x, int y) {
  return (x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h);
}

Play and Pause Button

The button shows a triangle when the music is paused and two bars when it plays. The icon is redrawn only when the state changes.

void drawPlayPauseButton() {
  tft.fillRect(PLAY_BTN.x - 4, PLAY_BTN.y - 4, PLAY_BTN.w + 8, PLAY_BTN.h + 8, ILI9341_BLACK);
  tft.fillRoundRect(PLAY_BTN.x, PLAY_BTN.y, PLAY_BTN.w, PLAY_BTN.h, 10, ILI9341_DARKGREY);

  int x = PLAY_BTN.x, y = PLAY_BTN.y;
  if (isPlaying) {
    tft.fillRect(x + 12, y + 10, 8, 28, ILI9341_WHITE);
    tft.fillRect(x + 28, y + 10, 8, 28, ILI9341_WHITE);
  } else {
    tft.fillTriangle(x + 14, y + 10, x + 14, y + 38, x + 36, y + 24, ILI9341_WHITE);
  }
}

The touch handler toggles the state and sends the matching command. The DFPlayer resumes a paused track with start(). A call to play() or playMp3Folder() would restart the track from the beginning, and the progress bar would no longer match the music.

void togglePlayPause() {
  isPlaying = !isPlaying;
  if (isPlaying) myDFPlayer.start();    // Resume from the pause position
  else           myDFPlayer.pause();
  drawPlayPauseButton();
}
Animated capture of the play button changing to a pause icon when a track is playing and back to play when it is paused

Next and Previous Buttons

The next and previous buttons wrap around at the ends of the list, so the last track is followed by the first one.

void nextTrack() {
  playTrack((currentTrack + 1) % totalTracks);
  if (currentScreen == PLAYER) drawPlayerScreen();
}

void prevTrack() {
  playTrack((currentTrack + totalTracks - 1) % totalTracks);
  if (currentScreen == PLAYER) drawPlayerScreen();
}

The screen is redrawn only when the player screen is active. This matters when a track ends while you are looking at the playlist, because the sketch then starts the next track in the background without drawing over the playlist.

Animated capture of the next and previous buttons switching tracks and updating the title and progress bar on the ILI9341 display

Detect Taps Instead of Holds

The loop checks the touch panel every 30 ms. A finger that rests on a button for half a second would trigger the button about fifteen times, and the player would skip through the tracks. The sketch therefore compares each reading with the previous one and treats only the first reading of a touch as a tap.

bool touched = readTouch(tx, ty);
bool tap = touched && !wasTouched;   // True only on the first reading of a touch
wasTouched = touched;

The buttons react to taps, and the volume slider reacts to every reading, so that you can drag it.


MP3 Player Animations

Animations make the player feel alive. They do not follow the real audio, because the DFPlayer gives no access to the signal, but they run only while a track plays and stop when you pause it. Both animations redraw small areas only.

Equalizer Bars

Five bars rise and fall with random heights. Each pass draws or erases only the difference between the old and the new height, which avoids flicker.

void updateEqualizer() {
  int startX = (SCR_W - (5 * EQ_BAR_W + 4 * EQ_GAP)) / 2;

  for (int i = 0; i < 5; i++) {
    int x = startX + i * (EQ_BAR_W + EQ_GAP);
    int h = random(10, EQ_MAX_H);

    if (h > prevEQ[i]) {            // Taller: draw the extra part
      tft.fillRect(x, EQ_BASE_Y - h, EQ_BAR_W, h - prevEQ[i], ILI9341_GREEN);
    } else if (h < prevEQ[i]) {     // Shorter: erase the difference
      tft.fillRect(x, EQ_BASE_Y - prevEQ[i], EQ_BAR_W, prevEQ[i] - h, ILI9341_BLACK);
    }
    prevEQ[i] = h;
  }
}

The loop calls this function about every 110 ms, which gives roughly nine frames per second.


Rotating Disc

The disc is a light grey circle with a black hub and one blue spoke. Each frame erases the old spoke by drawing it again in the disc color and then draws the spoke at the next angle, so the circle is never cleared and redrawn.

void updateCD() {
  tft.drawLine(CD_X, CD_Y, prevCdX, prevCdY, ILI9341_LIGHTGREY);   // Erase the old spoke

  cdAngle = (cdAngle + 10) % 360;
  int x = CD_X + (CD_R - 2) * cos(radians(cdAngle));
  int y = CD_Y + (CD_R - 2) * sin(radians(cdAngle));

  tft.drawLine(CD_X, CD_Y, x, y, ILI9341_BLUE);
  tft.fillCircle(CD_X, CD_Y, 6, ILI9341_BLACK);                     // Keep the hub clean

  prevCdX = x;
  prevCdY = y;
}

The FreeRTOS tutorials for Arduino show how to run animations as separate tasks if you want to separate them from the touch handling.


MP3 Player Volume Slider

The volume slider is a horizontal bar with a marker. The DFPlayer accepts volume values from 0 to 30, and the slider converts the touch position into that range with map().

Draw the Slider

void drawVolumeSlider() {
  tft.fillRect(VOL_BAR.x - 8, VOL_BAR.y - 8, VOL_BAR.w + 16, VOL_BAR.h + 16, ILI9341_BLACK);
  tft.fillRect(VOL_BAR.x, VOL_BAR.y, VOL_BAR.w, VOL_BAR.h, ILI9341_DARKGREY);

  int filled = map(volumeLevel, 0, 30, 0, VOL_BAR.w);
  tft.fillRect(VOL_BAR.x, VOL_BAR.y, filled, VOL_BAR.h, ILI9341_GREEN);
  tft.fillCircle(VOL_BAR.x + filled, VOL_BAR.y + VOL_BAR.h / 2, 6, ILI9341_WHITE);
}

Change the Volume by Touch

The handler checks whether the touch is inside the slider area. If it is, the horizontal position becomes the new volume, and the sketch sends it to the DFPlayer only when the value has changed.

void handleVolumeTouch(int tx, int ty) {
  if (tx >= VOL_BAR.x - 8 && tx <= VOL_BAR.x + VOL_BAR.w + 8 &&
      ty >= VOL_BAR.y - 10 && ty <= VOL_BAR.y + VOL_BAR.h + 10) {

    int newVolume = constrain(map(tx - VOL_BAR.x, 0, VOL_BAR.w, 0, 30), 0, 30);

    if (newVolume != volumeLevel) {
      volumeLevel = newVolume;
      myDFPlayer.volume(volumeLevel);
      drawVolumeSlider();
    }
  }
}

Sliding to the left lowers the volume, and sliding to the right raises it. The marker follows the finger because the slider is redrawn after every change.


MP3 Player Screen Navigation

The interface has two screens, and a variable of the type Screen stores which one is active. All touch events pass through one function, handleTouch(), which decides what the touch means on the current screen.

enum Screen { PLAYLIST, PLAYER };
Screen currentScreen = PLAYLIST;

void handleTouch() {
  static bool wasTouched = false;
  int tx, ty;

  bool touched = readTouch(tx, ty);
  bool tap = touched && !wasTouched;
  wasTouched = touched;
  if (!touched) return;

  if (currentScreen == PLAYLIST) {
    if (tap) handlePlaylistTouch(ty);
  } else {
    if (tap && inside(MENU_BTN, tx, ty)) {      // Back to the playlist
      currentScreen = PLAYLIST;
      drawPlaylistScreen();
      return;
    }
    if (tap) handleControlTouch(tx, ty);        // Play, pause, next, previous
    handleVolumeTouch(tx, ty);                  // The slider can be dragged
  }
}

The flow is simple.

  1. On the playlist screen, a tap on a row plays the track and switches to the player screen.
  2. On the player screen, a tap on the menu button switches back to the playlist, and the music keeps playing.
  3. A tap on a playback button changes the track or the play state, and a drag on the slider changes the volume.

A full-screen redraw happens only when the screen changes, and the animations and the progress bar update small areas, so the interface stays responsive and without flicker. The same two-screen pattern is used in the multi-page menu of the ILI9341 tutorial, and you can extend it with more screens.


Arduino MP3 Player Full Code

The sketch below combines the playlist screen, the player screen, the touch buttons, the animations, the volume slider and the DFPlayer control. Before you upload it, make three changes.

  1. Run the calibration sketch from Part 1 with tft.setRotation(2) and ts.setRotation(0), which are the rotations that this sketch uses, and copy the four values into RAW_X1, RAW_Y1, RAW_X2 and RAW_Y2. The values in the sketch are only examples.
  2. Enter the real length of each track in the trackSeconds array, so that the progress bar matches the music.
  3. Copy your tracks to the MP3 folder of the card as 0001.mp3, 0002.mp3 and so on, and adjust the track list to match.

If the picture is upside down in your enclosure, use tft.setRotation(0) and ts.setRotation(2), and run the calibration again.

#include <Adafruit_GFX.h>
#include <Adafruit_ILI9341.h>
#include <XPT2046_Touchscreen.h>
#include <SoftwareSerial.h>
#include "DFRobotDFPlayerMini.h"

// ---------- Pins ----------
#define TFT_CS   10
#define TFT_DC    9
#define TFT_RST   8
#define TS_CS     6
#define DF_RX     4   // Arduino receive pin, connected to the DFPlayer TX
#define DF_TX     5   // Arduino transmit pin, connected to the DFPlayer RX (1 k resistor)

// ---------- Touch calibration (portrait: tft rotation 2, touch rotation 0) ----------
// Replace these example values with the ones from your own calibration run
const int RAW_X1 = 350, RAW_Y1 = 505, RAW_X2 = 3549, RAW_Y2 = 3795;
const int MARGIN = 20;

// ---------- Screen size (portrait) ----------
const int SCR_W = 240;
const int SCR_H = 320;

// ---------- Objects ----------
Adafruit_ILI9341 tft = Adafruit_ILI9341(TFT_CS, TFT_DC, TFT_RST);
XPT2046_Touchscreen ts(TS_CS);
SoftwareSerial dfSerial(DF_RX, DF_TX);
DFRobotDFPlayerMini myDFPlayer;

// ---------- Tracks ----------
const char *trackList[] = {"Track 01", "Track 02", "Track 03", "Track 04", "Track 05"};
const int trackSeconds[] = {40, 40, 40, 40, 40};   // Length of each track in seconds
const int totalTracks = 5;
int currentTrack = 0;

// ---------- Layout ----------
struct Rect { int x, y, w, h; };

const Rect MENU_BTN = {204, 2, 32, 28};
const Rect PREV_BTN = {10, 200, 60, 48};
const Rect PLAY_BTN = {96, 200, 48, 48};
const Rect NEXT_BTN = {170, 200, 60, 48};
const Rect VOL_BAR  = {20, 270, 200, 12};

const int TITLE_H = 32;
const int BAR_X = 10, BAR_Y = 40, BAR_W = 220, BAR_H = 8;
const int EQ_BASE_Y = 125, EQ_MAX_H = 60, EQ_BAR_W = 12, EQ_GAP = 14;
const int CD_X = 120, CD_Y = 160, CD_R = 28;
const uint16_t BTN_COLOR = ILI9341_BLUE;

// ---------- State ----------
enum Screen { PLAYLIST, PLAYER };
Screen currentScreen = PLAYLIST;

bool isPlaying = false;
int elapsedTime = 0;
int volumeLevel = 20;          // 0 to 30

int prevBarWidth = 0;
int prevEQ[5] = {0, 0, 0, 0, 0};
int cdAngle = 0;
int prevCdX = CD_X + CD_R - 2;
int prevCdY = CD_Y;

// ---------- Setup ----------
void setup() {
  Serial.begin(9600);
  dfSerial.begin(9600);
  randomSeed(analogRead(A0));

  tft.begin();
  tft.setRotation(2);
  ts.begin();
  ts.setRotation(0);
  tft.fillScreen(ILI9341_BLACK);

  delay(1000);                          // The DFPlayer needs a moment to start
  if (!myDFPlayer.begin(dfSerial)) {
    tft.setTextSize(2);
    tft.setTextColor(ILI9341_RED);
    tft.setCursor(10, 100);
    tft.print("DFPlayer error");
    tft.setTextSize(1);
    tft.setTextColor(ILI9341_WHITE);
    tft.setCursor(10, 130);
    tft.print("Check the wiring and the SD card");
    Serial.println(F("DFPlayer error. Check the wiring and the SD card."));
    while (true);
  }

  myDFPlayer.setTimeOut(500);
  myDFPlayer.volume(volumeLevel);
  drawPlaylistScreen();
}

// ---------- Main loop ----------
void loop() {
  static unsigned long lastTouch = 0, lastEQ = 0, lastProgress = 0, lastCD = 0, lastSec = 0;
  unsigned long now = millis();

  if (now - lastTouch >= 30) {
    lastTouch = now;
    handleTouch();
  }

  if (currentScreen == PLAYER) {
    if (isPlaying && now - lastEQ >= 110) {
      lastEQ = now;
      updateEqualizer();
    }
    if (isPlaying && now - lastCD >= 60) {
      lastCD = now;
      updateCD();
    }
    if (now - lastProgress >= 150) {
      lastProgress = now;
      updateProgressBar();
    }
  }

  // Count the elapsed seconds while a track plays
  if (isPlaying && now - lastSec >= 1000) {
    lastSec = now;
    elapsedTime++;
    if (elapsedTime > trackSeconds[currentTrack]) nextTrack();
  }
}

// ---------- Touch handling ----------
bool readTouch(int &sx, int &sy) {
  if (!ts.touched()) return false;
  TS_Point p = ts.getPoint();
  sx = constrain(map(p.x, RAW_X1, RAW_X2, MARGIN, SCR_W - MARGIN), 0, SCR_W - 1);
  sy = constrain(map(p.y, RAW_Y1, RAW_Y2, MARGIN, SCR_H - MARGIN), 0, SCR_H - 1);
  return true;
}

bool inside(const Rect &r, int x, int y) {
  return (x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h);
}

void handleTouch() {
  static bool wasTouched = false;
  int tx, ty;

  bool touched = readTouch(tx, ty);
  bool tap = touched && !wasTouched;     // True only on the first reading of a touch
  wasTouched = touched;
  if (!touched) return;

  if (currentScreen == PLAYLIST) {
    if (tap) handlePlaylistTouch(ty);
  } else {
    if (tap && inside(MENU_BTN, tx, ty)) {
      currentScreen = PLAYLIST;
      drawPlaylistScreen();
      return;
    }
    if (tap) handleControlTouch(tx, ty);
    handleVolumeTouch(tx, ty);           // The slider can be dragged
  }
}

void handlePlaylistTouch(int ty) {
  if (ty < 40) return;

  int row = (ty - 40) / 40;
  if (row >= 0 && row < totalTracks) {
    playTrack(row);
    currentScreen = PLAYER;
    drawPlayerScreen();
  }
}

void handleControlTouch(int tx, int ty) {
  if (inside(PLAY_BTN, tx, ty))      togglePlayPause();
  else if (inside(NEXT_BTN, tx, ty)) nextTrack();
  else if (inside(PREV_BTN, tx, ty)) prevTrack();
}

void handleVolumeTouch(int tx, int ty) {
  if (tx >= VOL_BAR.x - 8 && tx <= VOL_BAR.x + VOL_BAR.w + 8 &&
      ty >= VOL_BAR.y - 10 && ty <= VOL_BAR.y + VOL_BAR.h + 10) {

    int newVolume = constrain(map(tx - VOL_BAR.x, 0, VOL_BAR.w, 0, 30), 0, 30);

    if (newVolume != volumeLevel) {
      volumeLevel = newVolume;
      myDFPlayer.volume(volumeLevel);
      drawVolumeSlider();
    }
  }
}

// ---------- Playback control ----------
void playTrack(int index) {
  currentTrack = index;
  elapsedTime = 0;
  isPlaying = true;
  myDFPlayer.playMp3Folder(currentTrack + 1);   // Plays /MP3/0001.mp3, 0002.mp3, ...
}

void nextTrack() {
  playTrack((currentTrack + 1) % totalTracks);
  if (currentScreen == PLAYER) drawPlayerScreen();
}

void prevTrack() {
  playTrack((currentTrack + totalTracks - 1) % totalTracks);
  if (currentScreen == PLAYER) drawPlayerScreen();
}

void togglePlayPause() {
  isPlaying = !isPlaying;
  if (isPlaying) myDFPlayer.start();     // Resume from the pause position
  else           myDFPlayer.pause();
  drawPlayPauseButton();
}

// ---------- Playlist screen ----------
void drawPlaylistScreen() {
  tft.fillScreen(ILI9341_BLACK);

  tft.setTextSize(1);
  tft.setTextColor(ILI9341_WHITE);
  tft.setCursor(10, 16);
  tft.print("Tap a track to play");

  tft.setTextSize(2);
  for (int i = 0; i < totalTracks; i++) {
    int y = 40 + i * 40;
    uint16_t bg = (i % 2 == 0) ? ILI9341_DARKGREY : ILI9341_NAVY;
    tft.fillRect(0, y, SCR_W, 40, bg);
    tft.setCursor(10, y + 12);
    tft.print(trackList[i]);
  }
}

// ---------- Player screen ----------
void drawPlayerScreen() {
  tft.fillScreen(ILI9341_BLACK);

  // Title bar and menu button
  tft.fillRect(0, 0, SCR_W, TITLE_H, ILI9341_DARKGREY);
  tft.setTextSize(2);
  tft.setTextColor(ILI9341_WHITE);
  tft.setCursor(8, 9);
  tft.print(trackList[currentTrack]);

  tft.fillRect(MENU_BTN.x, MENU_BTN.y, MENU_BTN.w, MENU_BTN.h, ILI9341_NAVY);
  for (int i = 0; i < 3; i++) {
    tft.fillRect(MENU_BTN.x + 8, MENU_BTN.y + 6 + i * 7, 16, 3, ILI9341_WHITE);
  }

  // Progress bar background
  tft.fillRect(BAR_X, BAR_Y, BAR_W, BAR_H, ILI9341_DARKGREY);
  prevBarWidth = 0;

  // Reset the equalizer and draw the disc
  for (int i = 0; i < 5; i++) prevEQ[i] = 0;
  drawCDDisc();

  drawPrevButton();
  drawPlayPauseButton();
  drawNextButton();
  drawVolumeSlider();
  updateProgressBar();
}

void updateProgressBar() {
  int w = constrain(map(elapsedTime, 0, trackSeconds[currentTrack], 0, BAR_W), 0, BAR_W);

  if (w > prevBarWidth) {
    uint16_t color = ILI9341_GREEN;
    if (elapsedTime * 10 >= trackSeconds[currentTrack] * 9)      color = ILI9341_RED;
    else if (elapsedTime * 10 >= trackSeconds[currentTrack] * 6) color = ILI9341_ORANGE;

    tft.fillRect(BAR_X + prevBarWidth, BAR_Y, w - prevBarWidth, BAR_H, color);
    prevBarWidth = w;
  }
}

void updateEqualizer() {
  int startX = (SCR_W - (5 * EQ_BAR_W + 4 * EQ_GAP)) / 2;

  for (int i = 0; i < 5; i++) {
    int x = startX + i * (EQ_BAR_W + EQ_GAP);
    int h = random(10, EQ_MAX_H);

    if (h > prevEQ[i]) {
      tft.fillRect(x, EQ_BASE_Y - h, EQ_BAR_W, h - prevEQ[i], ILI9341_GREEN);
    } else if (h < prevEQ[i]) {
      tft.fillRect(x, EQ_BASE_Y - prevEQ[i], EQ_BAR_W, prevEQ[i] - h, ILI9341_BLACK);
    }
    prevEQ[i] = h;
  }
}

void drawCDDisc() {
  tft.fillCircle(CD_X, CD_Y, CD_R, ILI9341_LIGHTGREY);
  tft.fillCircle(CD_X, CD_Y, 6, ILI9341_BLACK);
  prevCdX = CD_X + CD_R - 2;
  prevCdY = CD_Y;
}

void updateCD() {
  tft.drawLine(CD_X, CD_Y, prevCdX, prevCdY, ILI9341_LIGHTGREY);   // Erase the old spoke

  cdAngle = (cdAngle + 10) % 360;
  int x = CD_X + (CD_R - 2) * cos(radians(cdAngle));
  int y = CD_Y + (CD_R - 2) * sin(radians(cdAngle));

  tft.drawLine(CD_X, CD_Y, x, y, ILI9341_BLUE);
  tft.fillCircle(CD_X, CD_Y, 6, ILI9341_BLACK);

  prevCdX = x;
  prevCdY = y;
}

// ---------- Buttons ----------
void drawPrevButton() {
  tft.fillRoundRect(PREV_BTN.x, PREV_BTN.y, PREV_BTN.w, PREV_BTN.h, 10, BTN_COLOR);
  int cx = PREV_BTN.x + PREV_BTN.w / 2;
  int cy = PREV_BTN.y + PREV_BTN.h / 2;
  tft.fillRect(cx - 14, cy - 10, 4, 20, ILI9341_WHITE);
  tft.fillTriangle(cx + 12, cy - 10, cx + 12, cy + 10, cx - 6, cy, ILI9341_WHITE);
}

void drawNextButton() {
  tft.fillRoundRect(NEXT_BTN.x, NEXT_BTN.y, NEXT_BTN.w, NEXT_BTN.h, 10, BTN_COLOR);
  int cx = NEXT_BTN.x + NEXT_BTN.w / 2;
  int cy = NEXT_BTN.y + NEXT_BTN.h / 2;
  tft.fillRect(cx + 10, cy - 10, 4, 20, ILI9341_WHITE);
  tft.fillTriangle(cx - 12, cy - 10, cx - 12, cy + 10, cx + 6, cy, ILI9341_WHITE);
}

void drawPlayPauseButton() {
  tft.fillRect(PLAY_BTN.x - 4, PLAY_BTN.y - 4, PLAY_BTN.w + 8, PLAY_BTN.h + 8, ILI9341_BLACK);
  tft.fillRoundRect(PLAY_BTN.x, PLAY_BTN.y, PLAY_BTN.w, PLAY_BTN.h, 10, ILI9341_DARKGREY);

  int x = PLAY_BTN.x, y = PLAY_BTN.y;
  if (isPlaying) {
    tft.fillRect(x + 12, y + 10, 8, 28, ILI9341_WHITE);
    tft.fillRect(x + 28, y + 10, 8, 28, ILI9341_WHITE);
  } else {
    tft.fillTriangle(x + 14, y + 10, x + 14, y + 38, x + 36, y + 24, ILI9341_WHITE);
  }
}

void drawVolumeSlider() {
  tft.fillRect(VOL_BAR.x - 8, VOL_BAR.y - 8, VOL_BAR.w + 16, VOL_BAR.h + 16, ILI9341_BLACK);
  tft.fillRect(VOL_BAR.x, VOL_BAR.y, VOL_BAR.w, VOL_BAR.h, ILI9341_DARKGREY);

  int filled = map(volumeLevel, 0, 30, 0, VOL_BAR.w);
  tft.fillRect(VOL_BAR.x, VOL_BAR.y, filled, VOL_BAR.h, ILI9341_GREEN);
  tft.fillCircle(VOL_BAR.x + filled, VOL_BAR.y + VOL_BAR.h / 2, 6, ILI9341_WHITE);
}

The sketch has no Settings page. Themes, brightness and equalizer presets are good next steps, and the library supports the presets directly, for example myDFPlayer.EQ(DFPLAYER_EQ_BASS);. The other choices are DFPLAYER_EQ_NORMAL, DFPLAYER_EQ_POP, DFPLAYER_EQ_ROCK, DFPLAYER_EQ_JAZZ and DFPLAYER_EQ_CLASSIC.


Arduino MP3 Player Troubleshooting

Most problems come from the DFPlayer wiring, the SD card, the power supply or the touch calibration.

The Sketch Reports a DFPlayer Error

The begin() call fails when the module does not answer. Check these points in order.

  1. The DFPlayer TX pin goes to D4, and the DFPlayer RX pin goes to D5 through a resistor. Swapping the two wires is the most common mistake.
  2. The microSD card is in the slot, formatted as FAT32 and inserted before the module is powered.
  3. The module receives a stable 5 V and a common ground with the Arduino.

The standalone test sketch in the library section helps to separate a module problem from a display problem.


The Track Does Not Play or the Wrong Track Plays

  • The files must be in a folder named MP3 and named 0001.mp3, 0002.mp3 and so on.
  • The command playMp3Folder(n) plays the file with the number n, so the sketch plays the file 0001.mp3 for the first row.
  • Some cloned modules skip tracks that have consecutive numbers. If one of your tracks never plays, try numbering the files with odd numbers only, as in 0001.mp3, 0003.mp3 and 0005.mp3, and call playMp3Folder(2 * currentTrack + 1) in playTrack().

There Is Noise or Crackling in the Speaker

A weak supply is the usual cause. Place a capacitor of 100 µF to 470 µF across the VCC and GND pins of the DFPlayer, use a cleaner 5 V source and keep the speaker wires short. The resistor of 1 kΩ on the RX line also reduces digital noise. If the sound is still poor, use the DAC outputs with an external amplifier.


The Touch Is Not Accurate or Is Mirrored

Run the calibration from Part 1 again, using the same rotations as the sketch, and paste the new values. If the touch moves along the wrong axis, change the rotation of the touch panel first and calibrate after that.


The Screen Flickers

A flicker usually means that something redraws a large area in every pass. In this sketch, only the progress bar, the equalizer, the disc and the volume slider update, and each of them redraws only the part that changed. Avoid adding a full fillScreen() call inside loop().


Conclusion

This tutorial built an Arduino MP3 player with a DFPlayer Mini and an ILI9341 touchscreen. It covered the wiring, the SD card setup, a standalone module test, a playlist screen, a player screen with a progress bar, touch buttons, an equalizer and rotating disc animation, a volume slider and screen navigation. The key points are to prepare the card with the MP3 folder, to separate taps from holds, to resume a paused track with start() and to redraw only the areas that change. You can extend the player with more screens, folders, a settings page or a faster board.


Download Project Here

Download the CubeMX project files and HAL source code — all tested on real hardware.

If you find this useful, you can support us with a Donation as well.



About the Author
Arun Rawat
Arun Rawat
Embedded Systems Engineer · Founder, ControllersTech

Arun is an embedded systems engineer with 10+ years of experience in STM32, ESP32, and AVR microcontrollers. He created ControllersTech to share practical tutorials on embedded software, HAL drivers, RTOS, and hardware design — grounded in real industrial automation experience.

Subscribe
Notify of

0 Comments
Newest
Oldest Most Voted
×

Don’t Miss Future STM32 Tutorials

Join thousands of developers getting free guides, code examples, and updates.