#include #include #include #include #include // --- 핀 설정 --- #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 갱신 }