Initial project import
This commit is contained in:
+463
@@ -0,0 +1,463 @@
|
|||||||
|
#include <SPI.h>
|
||||||
|
#include <Adafruit_GFX.h>
|
||||||
|
#include <Adafruit_ILI9341.h>
|
||||||
|
#include <DFRobotDFPlayerMini.h>
|
||||||
|
#include <SD.h>
|
||||||
|
|
||||||
|
// --- 핀 설정 ---
|
||||||
|
#define TFT_DC 28
|
||||||
|
#define TFT_RST 27
|
||||||
|
#define TFT_CS 22
|
||||||
|
#define SD_CS 5
|
||||||
|
#define ENCODER_S1 14
|
||||||
|
#define ENCODER_S2 13
|
||||||
|
#define ENCODER_KEY 15
|
||||||
|
|
||||||
|
Adafruit_ILI9341 tft = Adafruit_ILI9341(TFT_CS, TFT_DC, TFT_RST);
|
||||||
|
DFRobotDFPlayerMini myDFPlayer;
|
||||||
|
|
||||||
|
struct Song {
|
||||||
|
String title;
|
||||||
|
String artist;
|
||||||
|
long totalSeconds;
|
||||||
|
};
|
||||||
|
|
||||||
|
Song playlist[100];
|
||||||
|
int totalTracks = 0;
|
||||||
|
int currentTrack = 1;
|
||||||
|
int volume = 15;
|
||||||
|
bool isVolumeMode = false;
|
||||||
|
bool isPaused = true;
|
||||||
|
bool isShuffle = false; // 셔플 상태 변수
|
||||||
|
|
||||||
|
float artistScrollX = 10;
|
||||||
|
int artistPixelWidth = 0;
|
||||||
|
|
||||||
|
// --- 엔코더 로직 ---
|
||||||
|
volatile int encoderPos = 0;
|
||||||
|
int lastReportedPos = 0;
|
||||||
|
static uint8_t encoderState = 0;
|
||||||
|
const int8_t KNOB_DIR[] = {0, -1, 1, 0, 1, 0, 0, -1, -1, 0, 0, 1, 0, 1, -1, 0};
|
||||||
|
|
||||||
|
void readEncoder() {
|
||||||
|
encoderState <<= 2;
|
||||||
|
encoderState |= (digitalRead(ENCODER_S1) << 1) | digitalRead(ENCODER_S2);
|
||||||
|
encoderPos += KNOB_DIR[encoderState & 0x0F];
|
||||||
|
}
|
||||||
|
|
||||||
|
unsigned long buttonPressTime = 0;
|
||||||
|
bool buttonActive = false;
|
||||||
|
bool feedbackGiven = false;
|
||||||
|
bool modeChanged = false;
|
||||||
|
bool shuffleChanged = false; // 셔플 중복 토글 방지
|
||||||
|
unsigned long lastEncoderMoveTime = 0;
|
||||||
|
bool isWaitingToPlay = false;
|
||||||
|
unsigned long songStartTime = 0;
|
||||||
|
unsigned long pausedTime = 0;
|
||||||
|
unsigned long lastBarUpdate = 0;
|
||||||
|
|
||||||
|
float scrollX = 10;
|
||||||
|
unsigned long lastScrollTime = 0;
|
||||||
|
int titlePixelWidth = 0;
|
||||||
|
|
||||||
|
// --- 볼륨 표시 ---
|
||||||
|
void renderVolumeText(bool erase) {
|
||||||
|
tft.setTextSize(2);
|
||||||
|
tft.setCursor(150, 295);
|
||||||
|
if (erase) {
|
||||||
|
tft.setTextColor(ILI9341_BLACK, ILI9341_BLACK);
|
||||||
|
} else {
|
||||||
|
tft.setTextColor(isVolumeMode ? ILI9341_ORANGE : ILI9341_LIGHTGREY, ILI9341_BLACK);
|
||||||
|
}
|
||||||
|
|
||||||
|
tft.print("VOL:");
|
||||||
|
int displayVol = map(volume, 0, 30, 0, 100);
|
||||||
|
if (displayVol < 10) tft.print(" ");
|
||||||
|
else if (displayVol < 100) tft.print(" ");
|
||||||
|
tft.print(displayVol);
|
||||||
|
}
|
||||||
|
|
||||||
|
String formatTime(long seconds) {
|
||||||
|
int m = seconds / 60;
|
||||||
|
int s = seconds % 60;
|
||||||
|
return (m < 10 ? "0" : "") + String(m) + ":" + (s < 10 ? "0" : "") + String(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setup() {
|
||||||
|
Serial1.setRX(1);
|
||||||
|
Serial1.setTX(0);
|
||||||
|
Serial1.begin(9600);
|
||||||
|
randomSeed(analogRead(26)); // 셔플용 랜덤 시드
|
||||||
|
|
||||||
|
pinMode(ENCODER_S1, INPUT_PULLUP);
|
||||||
|
pinMode(ENCODER_S2, INPUT_PULLUP);
|
||||||
|
pinMode(ENCODER_KEY, INPUT_PULLUP);
|
||||||
|
attachInterrupt(digitalPinToInterrupt(ENCODER_S1), readEncoder, CHANGE);
|
||||||
|
attachInterrupt(digitalPinToInterrupt(ENCODER_S2), readEncoder, CHANGE);
|
||||||
|
|
||||||
|
pinMode(TFT_CS, OUTPUT);
|
||||||
|
pinMode(SD_CS, OUTPUT);
|
||||||
|
digitalWrite(TFT_CS, HIGH);
|
||||||
|
digitalWrite(SD_CS, HIGH);
|
||||||
|
|
||||||
|
tft.begin();
|
||||||
|
tft.setRotation(0);
|
||||||
|
tft.setTextWrap(false);
|
||||||
|
|
||||||
|
tft.fillScreen(ILI9341_BLACK);
|
||||||
|
tft.setTextColor(0x7BEF); tft.setTextSize(1);
|
||||||
|
tft.setCursor(92, 125); tft.print("PRESENT");
|
||||||
|
tft.setTextColor(ILI9341_WHITE); tft.setTextSize(3);
|
||||||
|
tft.setCursor(45, 145); tft.print("Seojeong");
|
||||||
|
tft.drawFastHLine(85, 180, 70, 0x07FF);
|
||||||
|
|
||||||
|
bool sdReady = false;
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
if (SD.begin(SD_CS)) {
|
||||||
|
sdReady = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
delay(150);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sdReady) {
|
||||||
|
File listFile = SD.open("list.txt");
|
||||||
|
if (listFile) {
|
||||||
|
totalTracks = 0;
|
||||||
|
while (listFile.available() && totalTracks < 100) {
|
||||||
|
String line = listFile.readStringUntil('\n');
|
||||||
|
line.trim();
|
||||||
|
int firstDash = line.indexOf('-');
|
||||||
|
int lastDash = line.lastIndexOf('-');
|
||||||
|
if (firstDash != -1 && lastDash != firstDash) {
|
||||||
|
playlist[totalTracks].artist = line.substring(0, firstDash);
|
||||||
|
playlist[totalTracks].title = line.substring(firstDash + 1, lastDash);
|
||||||
|
String timeStr = line.substring(lastDash + 1);
|
||||||
|
int colon = timeStr.indexOf(':');
|
||||||
|
if (colon != -1) {
|
||||||
|
int m = timeStr.substring(0, colon).toInt();
|
||||||
|
int s = timeStr.substring(colon + 1).toInt();
|
||||||
|
playlist[totalTracks].totalSeconds = (m * 60) + s;
|
||||||
|
}
|
||||||
|
playlist[totalTracks].artist.trim();
|
||||||
|
playlist[totalTracks].title.trim();
|
||||||
|
totalTracks++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listFile.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalTracks > 0) {
|
||||||
|
// 1부터 totalTracks 사이의 숫자를 무작위로 선택
|
||||||
|
currentTrack = random(1, totalTracks + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (myDFPlayer.begin(Serial1)) {
|
||||||
|
// [수정] 부팅 직후 볼륨이 튀는 것을 방지하기 위해 초기화 지연 후 설정
|
||||||
|
delay(500);
|
||||||
|
myDFPlayer.volume(volume);
|
||||||
|
delay(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
delay(1200);
|
||||||
|
updateUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
static unsigned long lastSDCheck = 0;
|
||||||
|
if (millis() - lastSDCheck > 2000) { // 2초마다 체크 (성능 저하 방지)
|
||||||
|
lastSDCheck = millis();
|
||||||
|
if (!SD.exists("/")) { // 루트 경로가 보이지 않으면 카드 제거로 판단
|
||||||
|
handleSDError();
|
||||||
|
return; // 에러 상태일 때는 아래 기능을 수행하지 않음
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkEncoder();
|
||||||
|
handleButton();
|
||||||
|
handleSmartSelect();
|
||||||
|
updateProgressBar();
|
||||||
|
drawVolumeBlink();
|
||||||
|
drawVisualizer();
|
||||||
|
updateTitleScroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
void playNextTrack() {
|
||||||
|
if (isShuffle) {
|
||||||
|
currentTrack = random(1, totalTracks + 1);
|
||||||
|
} else {
|
||||||
|
currentTrack++;
|
||||||
|
if (currentTrack > totalTracks) currentTrack = 1;
|
||||||
|
}
|
||||||
|
playTrack(currentTrack);
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateTitleScroll() {
|
||||||
|
if (totalTracks == 0) return;
|
||||||
|
|
||||||
|
unsigned long now = millis();
|
||||||
|
if (now - lastScrollTime > 30) {
|
||||||
|
lastScrollTime = now;
|
||||||
|
|
||||||
|
// --- 제목(Title) 스크롤 로직 (기존) ---
|
||||||
|
if (titlePixelWidth > 220) {
|
||||||
|
scrollX -= 2;
|
||||||
|
if (scrollX < -(titlePixelWidth + 40)) scrollX = 240;
|
||||||
|
tft.setTextSize(3);
|
||||||
|
tft.setTextColor(ILI9341_YELLOW, ILI9341_BLACK);
|
||||||
|
tft.setCursor((int)scrollX, 55);
|
||||||
|
tft.print(playlist[currentTrack - 1].title);
|
||||||
|
tft.print(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 가수(Artist) 스크롤 로직 (추가) ---
|
||||||
|
if (artistPixelWidth > 220) {
|
||||||
|
artistScrollX -= 1.5; // 제목보다 약간 느리게 설정 가능
|
||||||
|
if (artistScrollX < -(artistPixelWidth + 40)) artistScrollX = 240;
|
||||||
|
tft.setTextSize(2);
|
||||||
|
tft.setTextColor(0xBDD7, ILI9341_BLACK);
|
||||||
|
tft.setCursor((int)artistScrollX, 95); // Y좌표 95 유지
|
||||||
|
tft.print(playlist[currentTrack - 1].artist);
|
||||||
|
tft.print(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 검은색 잔상 방지 (화면 끝 처리)
|
||||||
|
if (scrollX < 0 && scrollX > -20) tft.fillRect(0, 50, 10, 35, ILI9341_BLACK);
|
||||||
|
if (artistScrollX < 0 && artistScrollX > -20) tft.fillRect(0, 90, 10, 25, ILI9341_BLACK);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void checkEncoder() {
|
||||||
|
if (encoderPos != lastReportedPos) {
|
||||||
|
int diff = (encoderPos - lastReportedPos) / 4;
|
||||||
|
if (diff != 0) {
|
||||||
|
onEncoderMove(diff);
|
||||||
|
lastReportedPos = encoderPos;
|
||||||
|
if (!isVolumeMode) {
|
||||||
|
lastEncoderMoveTime = millis();
|
||||||
|
isWaitingToPlay = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void onEncoderMove(int dir) {
|
||||||
|
if (isVolumeMode) {
|
||||||
|
volume = constrain(volume + dir, 0, 30);
|
||||||
|
myDFPlayer.volume(volume);
|
||||||
|
renderVolumeText(false);
|
||||||
|
} else {
|
||||||
|
currentTrack += dir;
|
||||||
|
if (currentTrack > totalTracks) currentTrack = 1;
|
||||||
|
if (currentTrack < 1) currentTrack = totalTracks;
|
||||||
|
updateUI();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleButton() {
|
||||||
|
bool btnState = (digitalRead(ENCODER_KEY) == LOW);
|
||||||
|
|
||||||
|
if (btnState) {
|
||||||
|
if (!buttonActive) {
|
||||||
|
buttonActive = true;
|
||||||
|
buttonPressTime = millis();
|
||||||
|
feedbackGiven = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- [추가] 실시간 하단 가이드 메시지 ---
|
||||||
|
unsigned long pressDur = millis() - buttonPressTime;
|
||||||
|
tft.setTextSize(2);
|
||||||
|
|
||||||
|
if (pressDur > 3000) { // 3초 경과
|
||||||
|
tft.setTextColor(ILI9341_CYAN, ILI9341_BLACK);
|
||||||
|
tft.setCursor(10, 295);
|
||||||
|
tft.print("[SHUFFLE?] "); // 3초 후 떼면 셔플
|
||||||
|
}
|
||||||
|
else if (pressDur > 1000) { // 1초 경과
|
||||||
|
tft.setTextColor(ILI9341_ORANGE, ILI9341_BLACK);
|
||||||
|
tft.setCursor(10, 295);
|
||||||
|
tft.print("[VOL MODE?]"); // 1초 후 떼면 볼륨
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else { // 버튼을 뗐을 때
|
||||||
|
if (buttonActive) {
|
||||||
|
unsigned long duration = millis() - buttonPressTime;
|
||||||
|
|
||||||
|
// 동작 실행 후 하단 가이드 영역 지우기
|
||||||
|
tft.fillRect(0, 290, 130, 30, ILI9341_BLACK);
|
||||||
|
|
||||||
|
if (duration > 3000) {
|
||||||
|
isShuffle = !isShuffle;
|
||||||
|
updateUI();
|
||||||
|
} else if (duration > 1000) {
|
||||||
|
isVolumeMode = !isVolumeMode;
|
||||||
|
if (!isVolumeMode) showSaveMsg();
|
||||||
|
updateUI();
|
||||||
|
} else if (duration > 50) {
|
||||||
|
togglePause();
|
||||||
|
}
|
||||||
|
|
||||||
|
buttonActive = false;
|
||||||
|
feedbackGiven = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleSmartSelect() {
|
||||||
|
if (isWaitingToPlay && !isVolumeMode) {
|
||||||
|
if (millis() - lastEncoderMoveTime > 1500) {
|
||||||
|
playTrack(currentTrack);
|
||||||
|
isWaitingToPlay = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void playTrack(int trackNum) {
|
||||||
|
myDFPlayer.play(trackNum);
|
||||||
|
isPaused = false;
|
||||||
|
isWaitingToPlay = false;
|
||||||
|
songStartTime = millis();
|
||||||
|
updateUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
void togglePause() {
|
||||||
|
if (isPaused) {
|
||||||
|
if (songStartTime == 0) playTrack(currentTrack);
|
||||||
|
else {
|
||||||
|
myDFPlayer.start();
|
||||||
|
songStartTime += (millis() - pausedTime);
|
||||||
|
isPaused = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
myDFPlayer.pause(); pausedTime = millis(); isPaused = true;
|
||||||
|
}
|
||||||
|
updateUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateUI() {
|
||||||
|
tft.fillScreen(ILI9341_BLACK);
|
||||||
|
tft.setTextColor(ILI9341_WHITE, ILI9341_BLACK);
|
||||||
|
tft.setTextSize(2);
|
||||||
|
tft.setCursor(10, 20);
|
||||||
|
|
||||||
|
if (isWaitingToPlay) tft.print(">> SELECT");
|
||||||
|
else {
|
||||||
|
tft.print(isPaused ? "PAUSED" : "PLAYING");
|
||||||
|
if (isShuffle) {
|
||||||
|
tft.setTextColor(ILI9341_CYAN);
|
||||||
|
tft.print(" [SHUFFLE]"); // 셔플 활성화 표시
|
||||||
|
tft.setTextColor(ILI9341_WHITE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalTracks > 0) {
|
||||||
|
String currentTitle = playlist[currentTrack - 1].title;
|
||||||
|
titlePixelWidth = currentTitle.length() * 18;
|
||||||
|
scrollX = 10;
|
||||||
|
|
||||||
|
tft.setTextSize(3);
|
||||||
|
tft.setTextColor(ILI9341_YELLOW, ILI9341_BLACK);
|
||||||
|
tft.setCursor(10, 55);
|
||||||
|
tft.print(currentTitle);
|
||||||
|
|
||||||
|
|
||||||
|
String currentArtist = playlist[currentTrack - 1].artist;
|
||||||
|
artistPixelWidth = currentArtist.length() * 12; // TextSize 2이므로 글자당 약 12픽셀
|
||||||
|
artistScrollX = 10;
|
||||||
|
|
||||||
|
if (artistPixelWidth <= 220) {
|
||||||
|
tft.setTextSize(2);
|
||||||
|
tft.setTextColor(0xBDD7, ILI9341_BLACK);
|
||||||
|
tft.setCursor(10, 95);
|
||||||
|
tft.print(currentArtist);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tft.drawRect(18, 146, 204, 12, ILI9341_WHITE);
|
||||||
|
renderVolumeText(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateProgressBar() {
|
||||||
|
if (totalTracks == 0) return;
|
||||||
|
|
||||||
|
if (myDFPlayer.readType() == DFPlayerError) {
|
||||||
|
isPaused = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (millis() - lastBarUpdate > 500) {
|
||||||
|
lastBarUpdate = millis();
|
||||||
|
long elapsed = (isPaused || songStartTime == 0) ? (pausedTime - songStartTime) / 1000 : (millis() - songStartTime) / 1000;
|
||||||
|
if (songStartTime == 0) elapsed = 0;
|
||||||
|
long total = playlist[currentTrack - 1].totalSeconds;
|
||||||
|
|
||||||
|
tft.setTextSize(1);
|
||||||
|
tft.setTextColor(ILI9341_WHITE, ILI9341_BLACK);
|
||||||
|
tft.setCursor(20, 135); tft.print(formatTime(elapsed));
|
||||||
|
tft.setCursor(190, 135); tft.print(formatTime(total));
|
||||||
|
|
||||||
|
if (total > 0) {
|
||||||
|
int barWidth = map(constrain(elapsed, 0, total), 0, total, 0, 200);
|
||||||
|
tft.fillRect(20, 148, barWidth, 8, ILI9341_CYAN);
|
||||||
|
tft.fillRect(20 + barWidth, 148, 200 - barWidth, 8, 0x2104);
|
||||||
|
|
||||||
|
// 곡이 끝났을 때 자동 다음 곡 재생 로직
|
||||||
|
if (!isPaused && elapsed >= total) {
|
||||||
|
delay(500);
|
||||||
|
playNextTrack();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void drawVisualizer() {
|
||||||
|
if (isPaused) {
|
||||||
|
tft.fillRect(20, 210, 200, 45, ILI9341_BLACK);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
static unsigned long lastVis = 0;
|
||||||
|
if (millis() - lastVis > 120) {
|
||||||
|
lastVis = millis();
|
||||||
|
for (int i = 0; i < 8; i++) {
|
||||||
|
int h = random(5, 40);
|
||||||
|
int x = 25 + (i * 25);
|
||||||
|
tft.fillRect(x, 210, 15, 40, ILI9341_BLACK);
|
||||||
|
tft.fillRect(x, 250 - h, 15, h, 0xF81F);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void drawVolumeBlink() {
|
||||||
|
static unsigned long lastBlink = 0;
|
||||||
|
static bool show = true;
|
||||||
|
if (!isVolumeMode) return;
|
||||||
|
if (millis() - lastBlink > 400) {
|
||||||
|
lastBlink = millis();
|
||||||
|
show = !show;
|
||||||
|
renderVolumeText(!show);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void showSaveMsg() {
|
||||||
|
tft.fillRect(0, 285, 240, 35, ILI9341_DARKGREEN);
|
||||||
|
tft.setCursor(35, 295);
|
||||||
|
tft.setTextColor(ILI9341_WHITE);
|
||||||
|
tft.setTextSize(2);
|
||||||
|
tft.print("SETTINGS SAVED");
|
||||||
|
delay(800);
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleSDError() {
|
||||||
|
tft.fillScreen(ILI9341_RED);
|
||||||
|
tft.setTextColor(ILI9341_WHITE);
|
||||||
|
tft.setTextSize(2);
|
||||||
|
tft.setCursor(30, 140);
|
||||||
|
tft.print("SD CARD REMOVED!");
|
||||||
|
tft.setCursor(20, 170);
|
||||||
|
tft.print("Please Re-insert...");
|
||||||
|
myDFPlayer.stop(); // 재생 중단 명령
|
||||||
|
while (!SD.begin(SD_CS)) {
|
||||||
|
delay(500); // 재삽입 대기
|
||||||
|
}
|
||||||
|
updateUI(); // 복구 시 UI 갱신
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Player
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
- Type: Project files
|
||||||
|
- Source directory: `Player`
|
||||||
|
- Files: 1
|
||||||
|
- Related classification: standalone project folder; no exact duplicate top-level project was found.
|
||||||
|
|
||||||
|
## Root Files
|
||||||
|
|
||||||
|
- `Player.txt`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- This repository is intended to be stored as a private project repository on `git.chaegeon.com`.
|
||||||
|
- Sensitive configuration may exist in source files and is kept private by repository visibility.
|
||||||
|
- Exact duplicate top-level project folders were checked before repository preparation.
|
||||||
Reference in New Issue
Block a user