From a0f40175d48e2d765f042efef6fba97a5b831255 Mon Sep 17 00:00:00 2001 From: seo Date: Sun, 7 Jun 2026 01:08:16 +0900 Subject: [PATCH] Initial project import --- Player.txt | 463 +++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 18 +++ 2 files changed, 481 insertions(+) create mode 100644 Player.txt create mode 100644 README.md diff --git a/Player.txt b/Player.txt new file mode 100644 index 0000000..ba6e3a8 --- /dev/null +++ b/Player.txt @@ -0,0 +1,463 @@ +#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 갱신 +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..07581ef --- /dev/null +++ b/README.md @@ -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.