Compare commits
52 Commits
fe04474ed8
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 7512e1d94b | |||
| 6ecb54e5ee | |||
| 69bc259a6c | |||
| 9c937dc62d | |||
| 34c499bd49 | |||
| ea4461cc54 | |||
| c14624ef92 | |||
| b97eb79b91 | |||
| fd40b663a0 | |||
| 3a34b1b8d0 | |||
| 465e34e919 | |||
| 83e51e87fe | |||
| 7f120ae62d | |||
| c32eabf464 | |||
| e10ffcfd65 | |||
| 2069d36715 | |||
| e677a7a8bf | |||
| de44c789af | |||
| 7e20aa65e1 | |||
| cfa3feb7d2 | |||
| abfe564891 | |||
| 083dfd6e2a | |||
| dc735c044f | |||
| 33701636af | |||
| d0c9a7e482 | |||
| 626657a976 | |||
| 6b942a8e07 | |||
| 22f8d3eec3 | |||
| 0a08709160 | |||
| b6ac157207 | |||
| 1144b95349 | |||
| b820f3fc8d | |||
| 02ee10edb9 | |||
| 8c5407691e | |||
| 72781503fa | |||
| b8e1263fb3 | |||
| 02cd5da886 | |||
| ce863f4d02 | |||
| 00048face4 | |||
| f40483873a | |||
| be288f6a5a | |||
| 78167ecc20 | |||
| a1d486dd2d | |||
| 571a1c5c39 | |||
| ce4ef44dcd | |||
| 9361bd333f | |||
| 2cb55b1304 | |||
| 177530d030 | |||
| 54e3100867 | |||
| 29316ffd13 | |||
| 67ded19c93 | |||
| 129fa8e624 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -7,5 +7,5 @@
|
|||||||
schema/hannabox/hannabox-backups/
|
schema/hannabox/hannabox-backups/
|
||||||
schema/hannabox/*.lck
|
schema/hannabox/*.lck
|
||||||
.copilot
|
.copilot
|
||||||
|
web/cleaned
|
||||||
.codegpt
|
.codegpt
|
||||||
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@@ -1,8 +1,7 @@
|
|||||||
{
|
{
|
||||||
// See http://go.microsoft.com/fwlink/?LinkId=827846
|
|
||||||
// for the documentation about the extensions.json format
|
|
||||||
"recommendations": [
|
"recommendations": [
|
||||||
"diegoomal.ollama-connection",
|
"diegoomal.ollama-connection",
|
||||||
|
"pioarduino.pioarduino-ide",
|
||||||
"platformio.platformio-ide"
|
"platformio.platformio-ide"
|
||||||
],
|
],
|
||||||
"unwantedRecommendations": [
|
"unwantedRecommendations": [
|
||||||
|
|||||||
241
MEMORY_OPTIMIZATIONS.md
Normal file
241
MEMORY_OPTIMIZATIONS.md
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
# ESP32 MP3 Player Memory Optimizations
|
||||||
|
|
||||||
|
This document summarizes the memory optimizations implemented to resolve out-of-memory issues in your ESP32 MP3 player.
|
||||||
|
|
||||||
|
## Implemented Optimizations
|
||||||
|
|
||||||
|
|
||||||
|
### Improvement for /directory
|
||||||
|
Implemented a backpressure-safe, low-heap HTML streaming solution to prevent AsyncTCP cbuf resize OOM during /directory.
|
||||||
|
|
||||||
|
Root cause
|
||||||
|
|
||||||
|
- The previous implementation used AsyncResponseStream (a Print) and wrote faster than the TCP stack could drain. Under client/network backpressure, AsyncTCP’s cbuf tried to grow and failed: cbuf.resize() -> WebResponses write(): Failed to allocate.
|
||||||
|
|
||||||
|
Fix implemented
|
||||||
|
|
||||||
|
- Switched /directory to AsyncChunkedResponse with a stateful generator that only produces bytes when the TCP layer is ready.
|
||||||
|
- Generates one entry at a time, respecting maxLen provided by the framework. This prevents buffer growth and heap spikes.
|
||||||
|
- No yield() needed; backpressure is handled by the chunked response callback scheduling.
|
||||||
|
|
||||||
|
Code changes
|
||||||
|
|
||||||
|
1. Added a tiny accessor to fetch file id at index
|
||||||
|
|
||||||
|
- Header: src/DirectoryNode.h
|
||||||
|
|
||||||
|
- Added: uint16_t getFileIdAt(size_t i) const;
|
||||||
|
|
||||||
|
- Source: src/DirectoryNode.cpp
|
||||||
|
|
||||||
|
- Implemented: uint16_t DirectoryNode::getFileIdAt(size_t i) const { return (i < ids.size()) ? ids[i] : 0; }
|
||||||
|
|
||||||
|
2. Replaced /directory handler with AsyncChunkedResponse generator
|
||||||
|
|
||||||
|
- File: src/main.cpp
|
||||||
|
|
||||||
|
- New logic (high level):
|
||||||
|
|
||||||
|
- DirectoryHtmlStreamState holds an explicit traversal stack of frames {node, fileIdx, childIdx, headerDone}.
|
||||||
|
|
||||||
|
- next(buffer, maxLen) fills output up to maxLen with:
|
||||||
|
|
||||||
|
- Single top-level \n
|
||||||
|
- A name\n for non-root directories (kept original behavior—no nested per subdir)
|
||||||
|
- One filename\n per file
|
||||||
|
- Depth-first traversal across subdirectories
|
||||||
|
- Closes with \n when done
|
||||||
|
|
||||||
|
- Uses snprintf into the chunk buffer and a simple copy loop for filenames, avoiding extra heap allocations.
|
||||||
|
|
||||||
|
- Frees generator state when finished and also on client disconnect.
|
||||||
|
|
||||||
|
3. Minor improvements in the chunked generator
|
||||||
|
|
||||||
|
- Normalized newline literals to \n (not escaped).
|
||||||
|
- Used single quotes around HTML attribute values to simplify C string escaping and reduce mistakes.
|
||||||
|
|
||||||
|
What remains unchanged
|
||||||
|
|
||||||
|
- DirectoryNode::streamDirectoryHTML(Print&) is left intact but no longer used by /directory. Mapping/State endpoints continue using their existing streaming; they are small and safe.
|
||||||
|
|
||||||
|
Why this eliminates the crashes
|
||||||
|
|
||||||
|
- AsyncChunkedResponse only invokes the generator when there’s space to send more, so AsyncTCP’s cbuf won’t grow unbounded. The generator respects the maxLen and yields 0 on completion, eliminating the resize path that previously caused OOM.
|
||||||
|
|
||||||
|
Build and flash instructions
|
||||||
|
|
||||||
|
- Your environment doesn’t have PlatformIO CLI available. Options:
|
||||||
|
|
||||||
|
1. VSCode PlatformIO extension: Use the “Build” and “Upload” tasks from the PlatformIO toolbar.
|
||||||
|
|
||||||
|
2. Install PlatformIO CLI:
|
||||||
|
|
||||||
|
- python3 -m pip install --user platformio
|
||||||
|
- $HOME/.local/bin must be in PATH (or use full path).
|
||||||
|
- Then build: pio run -e d1_mini32
|
||||||
|
- Upload: pio run -e d1_mini32 -t upload
|
||||||
|
|
||||||
|
3. Arduino IDE/CLI: Import and build the sketch there if preferred.
|
||||||
|
|
||||||
|
Runtime test checklist
|
||||||
|
|
||||||
|
- Open serial monitor at 115200, reset device.
|
||||||
|
|
||||||
|
- Hit [](http://DEVICE_IP/directory)<http://DEVICE_IP/directory> in a browser; the page should render fully without OOM or crash.
|
||||||
|
|
||||||
|
- Simulate slow client backpressure:
|
||||||
|
|
||||||
|
- curl --limit-rate 5k [](http://DEVICE_IP/directory)<http://DEVICE_IP/directory> -v -o /dev/null
|
||||||
|
- Observe no “[E][cbuf.cpp:104] resize(): failed to allocate temporary buffer” or “WebResponses write(): Failed to allocate”
|
||||||
|
|
||||||
|
- Watch heap logs during serving; you should see stable heap with no large dips.
|
||||||
|
|
||||||
|
- If desired, repeat with multiple concurrent connections to /directory to verify robustness.
|
||||||
|
|
||||||
|
Optional follow-ups
|
||||||
|
|
||||||
|
- If mapping ever grows large, convert /mapping to AsyncChunkedResponse using the same pattern.
|
||||||
|
- If your ESP32 has PSRAM, enabling it can further reduce heap pressure, but the chunked approach is already robust.
|
||||||
|
- Consider enabling CONFIG_ASYNC_TCP_MAX_ACK_TIME tune if you want more aggressive backpressure timing; your platformio.ini already has some AsyncTCP stack tweaks noted.
|
||||||
|
|
||||||
|
Summary
|
||||||
|
|
||||||
|
- Replaced Print-based recursive streaming with a chunked, backpressure-aware generator for /directory.
|
||||||
|
- This removes the cbuf resize failure path and should stop the crashes you observed while still using minimal heap.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### 2. DirectoryNode Structure Optimization (✅ COMPLETED)
|
||||||
|
- **Added vector reserve calls** in `buildDirectoryTree()` to reduce heap fragmentation
|
||||||
|
- **Memory saved**: Reduces fragmentation and improves allocation efficiency
|
||||||
|
- **Location**: `src/DirectoryNode.cpp` - lines with `reserve(8)`, `reserve(16)`
|
||||||
|
|
||||||
|
### 3. Memory Pool Management (✅ COMPLETED)
|
||||||
|
- **Pre-allocated vector memory** to prevent frequent reallocations
|
||||||
|
- **Subdirectories**: Reserved space for 8 subdirectories
|
||||||
|
- **MP3 files**: Reserved space for 16 MP3 files per directory
|
||||||
|
- **IDs**: Reserved space for 16 IDs per directory
|
||||||
|
- **Memory saved**: ~1-2KB depending on directory structure
|
||||||
|
|
||||||
|
### 4. Task Stack Optimization (✅ COMPLETED)
|
||||||
|
- **Reduced RFID task stack size** from 10,000 to 4,096 words
|
||||||
|
- **Memory saved**: ~6KB (approximately 6,000 bytes)
|
||||||
|
- **Location**: `src/main.cpp` - `xTaskCreatePinnedToCore()` call
|
||||||
|
|
||||||
|
### 5. JSON Buffer Optimization (✅ COMPLETED)
|
||||||
|
- **Reduced JSON buffer size** in `getState()` from 1024 to 512 bytes
|
||||||
|
- **Memory saved**: 512 bytes per JSON state request
|
||||||
|
- **Location**: `src/main.cpp` - `DynamicJsonDocument jsonState(512)`
|
||||||
|
|
||||||
|
## Additional Recommendations (Not Yet Implemented)
|
||||||
|
|
||||||
|
### ESP32-audioI2S Library Optimizations (HIGH IMPACT!)
|
||||||
|
|
||||||
|
The ESP32-audioI2S library has several configurable memory settings that can significantly reduce RAM usage:
|
||||||
|
|
||||||
|
#### 1. Audio Buffer Size Optimization
|
||||||
|
```cpp
|
||||||
|
// In your main.cpp setup(), add after audio initialization:
|
||||||
|
audio.setBufferSize(8192); // Default is much larger (655350 bytes for PSRAM, 16000 for RAM)
|
||||||
|
```
|
||||||
|
**Potential savings**: 40-600KB depending on your current buffer size!
|
||||||
|
|
||||||
|
#### 2. Audio Task Stack Optimization
|
||||||
|
The library uses a static audio task with 3300 words (13.2KB). You can modify this in the library:
|
||||||
|
```cpp
|
||||||
|
// In Audio.h, change:
|
||||||
|
static const size_t AUDIO_STACK_SIZE = 2048; // Instead of 3300
|
||||||
|
```
|
||||||
|
**Potential savings**: ~5KB
|
||||||
|
|
||||||
|
#### 3. Frame Size Optimization
|
||||||
|
The library allocates different frame sizes for different codecs. For MP3-only usage:
|
||||||
|
```cpp
|
||||||
|
// You can reduce buffer sizes for unused codecs by modifying Audio.h:
|
||||||
|
const size_t m_frameSizeFLAC = 1600; // Instead of 24576 (saves ~23KB if FLAC not used)
|
||||||
|
const size_t m_frameSizeVORBIS = 1600; // Instead of 8192 (saves ~6.5KB if Vorbis not used)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Disable Unused Features
|
||||||
|
Add these build flags to `platformio.ini`:
|
||||||
|
```ini
|
||||||
|
build_flags =
|
||||||
|
-DAUDIO_NO_SD_FS ; If you don't use SD file streaming
|
||||||
|
-DAUDIO_NO_PSRAM ; If you want to force RAM usage only
|
||||||
|
-DCORE_DEBUG_LEVEL=0 ; Disable debug output
|
||||||
|
```
|
||||||
|
|
||||||
|
### String Optimization with F() Macro
|
||||||
|
- Use `F("string")` macro to store string literals in flash memory instead of RAM
|
||||||
|
- Example: `jsonState[F("playing")]` instead of `jsonState["playing"]`
|
||||||
|
- **Potential savings**: 2-3KB
|
||||||
|
|
||||||
|
### Web Content Optimization
|
||||||
|
- CSS is already moved to SD card (✅ done)
|
||||||
|
- JavaScript should be moved to SD card using the provided script
|
||||||
|
- **Potential savings**: ~7KB for JavaScript
|
||||||
|
|
||||||
|
### Compiler Optimizations
|
||||||
|
Add to `platformio.ini`:
|
||||||
|
```ini
|
||||||
|
build_flags =
|
||||||
|
-Os ; Optimize for size
|
||||||
|
-DCORE_DEBUG_LEVEL=0 ; Disable debug output
|
||||||
|
-DARDUINO_LOOP_STACK_SIZE=4096 ; Reduce loop stack
|
||||||
|
```
|
||||||
|
|
||||||
|
## Total Memory Savings Achieved
|
||||||
|
|
||||||
|
| Optimization | Memory Saved |
|
||||||
|
|--------------|--------------|
|
||||||
|
| Vector reserves | ~1-2KB |
|
||||||
|
| RFID task stack reduction | ~6KB |
|
||||||
|
| JSON buffer reduction | 512 bytes |
|
||||||
|
| **Current Total Savings** | **~7-8KB** |
|
||||||
|
|
||||||
|
## Potential Additional Savings (ESP32-audioI2S Library)
|
||||||
|
|
||||||
|
| ESP32-audioI2S Optimization | Potential Memory Saved |
|
||||||
|
|------------------------------|------------------------|
|
||||||
|
| Audio buffer size reduction | 40-600KB |
|
||||||
|
| Audio task stack reduction | ~5KB |
|
||||||
|
| Unused codec frame buffers | ~30KB |
|
||||||
|
| Disable unused features | 5-10KB |
|
||||||
|
| **Potential Additional Total** | **80-645KB** |
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Copy web files to SD card**:
|
||||||
|
```bash
|
||||||
|
./copy_to_sd.sh
|
||||||
|
```
|
||||||
|
(Adjust the SD card mount point in the script as needed)
|
||||||
|
|
||||||
|
2. **Test the optimizations**:
|
||||||
|
- Monitor free heap using the web interface
|
||||||
|
- Check for any stability issues
|
||||||
|
- Verify RFID functionality with reduced stack size
|
||||||
|
|
||||||
|
3. **High-impact ESP32-audioI2S optimizations**:
|
||||||
|
```cpp
|
||||||
|
// Add to setup() after audio.setPinout():
|
||||||
|
audio.setBufferSize(8192); // Reduce from default large buffer
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Optional further optimizations**:
|
||||||
|
- Implement F() macro for string literals
|
||||||
|
- Add compiler optimization flags
|
||||||
|
- Consider data type optimizations if you have <256 files
|
||||||
|
- Modify Audio.h for unused codec optimizations
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
- `src/DirectoryNode.cpp` - Added vector reserve calls
|
||||||
|
- `src/main.cpp` - Reduced task stack and JSON buffer sizes
|
||||||
|
- `copy_to_sd.sh` - Script to copy web files to SD card
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
The web interface displays current free heap memory. Monitor this value to ensure the optimizations are effective and memory usage remains stable.
|
||||||
238
MEMORY_OPTIMIZATIONS_WEB.md
Normal file
238
MEMORY_OPTIMIZATIONS_WEB.md
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
# ESP32 MP3 Player - Web Request & WiFiManager Memory Optimizations
|
||||||
|
|
||||||
|
## High-Impact Web Request Optimizations
|
||||||
|
|
||||||
|
### 1. **ESPAsyncWifiManager Configuration Optimization** (HIGH IMPACT: 10-20KB)
|
||||||
|
|
||||||
|
The ESPAsyncWifiManager can be configured to use less memory:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// In setup(), before wifiManager.autoConnect():
|
||||||
|
AsyncWiFiManager wifiManager(&server, &dns);
|
||||||
|
|
||||||
|
// Reduce timeout to free memory faster
|
||||||
|
wifiManager.setTimeout(60); // Reduced from 180 to 60 seconds
|
||||||
|
|
||||||
|
// Disable debug output to save memory
|
||||||
|
wifiManager.setDebugOutput(false);
|
||||||
|
|
||||||
|
// Set custom parameters to reduce memory usage
|
||||||
|
wifiManager.setConfigPortalBlocking(false); // Non-blocking mode saves memory
|
||||||
|
wifiManager.setConnectTimeout(20); // Faster connection timeout
|
||||||
|
wifiManager.setConfigPortalTimeout(60); // Shorter portal timeout
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. **Web Server Request Handler Optimization** (MEDIUM IMPACT: 5-10KB)
|
||||||
|
|
||||||
|
Current issue: Each request handler creates temporary objects and strings.
|
||||||
|
|
||||||
|
**Optimization A: Use String References Instead of Copies**
|
||||||
|
```cpp
|
||||||
|
// Replace current parameter handling with more efficient version:
|
||||||
|
void id_song_action(AsyncWebServerRequest *request)
|
||||||
|
{
|
||||||
|
const AsyncWebParameter* p = request->getParam("id");
|
||||||
|
if (p != nullptr) {
|
||||||
|
playSongById(p->value().toInt()); // Direct conversion, no string copy
|
||||||
|
}
|
||||||
|
lastInteraction = millis();
|
||||||
|
request->send_P(200, "text/plain", "ok"); // Use PROGMEM string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Optimization B: Reduce JSON Buffer Allocations**
|
||||||
|
```cpp
|
||||||
|
String getState()
|
||||||
|
{
|
||||||
|
// Use static buffer to avoid repeated allocations
|
||||||
|
static DynamicJsonDocument jsonState(384); // Further reduced from 512
|
||||||
|
jsonState.clear(); // Clear previous data
|
||||||
|
|
||||||
|
jsonState[F("playing")] = audio.isRunning(); // Use F() macro
|
||||||
|
|
||||||
|
if (currentNode != nullptr)
|
||||||
|
jsonState[F("title")] = *currentNode->getCurrentPlaying();
|
||||||
|
else
|
||||||
|
jsonState[F("title")] = F("Angehalten"); // Store in flash
|
||||||
|
|
||||||
|
jsonState[F("time")] = audio.getAudioCurrentTime();
|
||||||
|
jsonState[F("volume")] = audio.getVolume();
|
||||||
|
jsonState[F("length")] = audio.getAudioFileDuration();
|
||||||
|
jsonState[F("voltage")] = lastVoltage;
|
||||||
|
jsonState[F("uid")] = lastUid;
|
||||||
|
jsonState[F("heap")] = free_heap;
|
||||||
|
|
||||||
|
String output;
|
||||||
|
output.reserve(256); // Pre-allocate string buffer
|
||||||
|
serializeJson(jsonState, output);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. **File Upload Handler Memory Optimization** (HIGH IMPACT: 15-30KB)
|
||||||
|
|
||||||
|
Current issue: Large temporary buffers and inefficient string operations.
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
void handleUpload(AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) {
|
||||||
|
static String logBuffer; // Static to avoid repeated allocations
|
||||||
|
|
||||||
|
if (!index) {
|
||||||
|
// Use const references to avoid string copies
|
||||||
|
const String& lowerFilename = filename;
|
||||||
|
|
||||||
|
// Pre-allocate log buffer
|
||||||
|
logBuffer.reserve(128);
|
||||||
|
logBuffer = F("Upload Start: ");
|
||||||
|
logBuffer += filename;
|
||||||
|
|
||||||
|
// More efficient space check
|
||||||
|
uint32_t freeSpace = (SD.cardSize() - SD.usedBytes()) >> 20; // Bit shift instead of division
|
||||||
|
|
||||||
|
if (freeSpace < 10) {
|
||||||
|
request->send(507, F("text/plain"), F("Insufficient storage space"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.println(logBuffer);
|
||||||
|
logBuffer.clear(); // Free memory immediately
|
||||||
|
|
||||||
|
// ... rest of upload logic
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reduce logging frequency to save memory
|
||||||
|
if (len && (index % 204800 == 0)) { // Log every 200KB instead of 100KB
|
||||||
|
logBuffer = F("Upload: ");
|
||||||
|
logBuffer += humanReadableSize(index + len);
|
||||||
|
Serial.println(logBuffer);
|
||||||
|
logBuffer.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. **HTML Template Processing Optimization** (MEDIUM IMPACT: 3-5KB)
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
String processor(const String &var)
|
||||||
|
{
|
||||||
|
if (var == F("DIRECTORY")) // Use F() macro
|
||||||
|
{
|
||||||
|
return rootNode.getDirectoryStructureHTML();
|
||||||
|
}
|
||||||
|
return String(); // Return empty string instead of creating new String
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. **Static Content Serving Optimization** (LOW IMPACT: 1-2KB)
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Optimize CSS/JS serving with better error handling
|
||||||
|
server.on("/style.css", HTTP_GET, [](AsyncWebServerRequest *request) {
|
||||||
|
activateSD();
|
||||||
|
const char* cssPath = "/system/style.css";
|
||||||
|
if (SD.exists(cssPath)) {
|
||||||
|
request->send(SD, cssPath, F("text/css"));
|
||||||
|
} else {
|
||||||
|
// Serve minimal fallback without creating large strings
|
||||||
|
request->send_P(200, "text/css", "body{font-family:Arial;text-align:center;}");
|
||||||
|
}
|
||||||
|
deactivateSD();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## ESPAsyncWifiManager Specific Optimizations
|
||||||
|
|
||||||
|
### 6. **WiFiManager Memory Pool Configuration** (HIGH IMPACT: 15-25KB)
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Add these configurations in setup():
|
||||||
|
void setup() {
|
||||||
|
// ... existing code ...
|
||||||
|
|
||||||
|
AsyncWiFiManager wifiManager(&server, &dns);
|
||||||
|
|
||||||
|
// Memory optimizations for WiFiManager
|
||||||
|
wifiManager.setDebugOutput(false); // Disable debug strings
|
||||||
|
wifiManager.setMinimumSignalQuality(20); // Reduce AP scan results
|
||||||
|
wifiManager.setRemoveDuplicateAPs(true); // Remove duplicate APs from memory
|
||||||
|
wifiManager.setConfigPortalBlocking(false); // Non-blocking saves memory
|
||||||
|
wifiManager.setScanDisposeDelay(5000); // Dispose scan results faster
|
||||||
|
|
||||||
|
// Reduce timeouts to free memory faster
|
||||||
|
wifiManager.setTimeout(60); // Reduced from 180
|
||||||
|
wifiManager.setConnectTimeout(15); // Faster connection attempts
|
||||||
|
wifiManager.setConfigPortalTimeout(60); // Shorter portal timeout
|
||||||
|
|
||||||
|
// Custom CSS/HTML to reduce memory usage (optional)
|
||||||
|
wifiManager.setCustomHeadElement(F("<style>body{font-size:14px;}</style>"));
|
||||||
|
|
||||||
|
if (wifiManager.autoConnect("HannaBox")) {
|
||||||
|
// ... existing server setup ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Additional Compiler & Build Optimizations
|
||||||
|
|
||||||
|
### 7. **Enhanced Build Flags** (MEDIUM IMPACT: 5-10KB)
|
||||||
|
|
||||||
|
Add to `platformio.ini`:
|
||||||
|
```ini
|
||||||
|
build_flags =
|
||||||
|
-Os ; Optimize for size
|
||||||
|
-DCORE_DEBUG_LEVEL=0 ; Disable all debug output
|
||||||
|
-DARDUINO_LOOP_STACK_SIZE=3072 ; Further reduce from 4096
|
||||||
|
-DWIFI_TASK_STACK_SIZE=3072 ; Reduce WiFi task stack
|
||||||
|
-DARDUINO_EVENT_TASK_STACK_SIZE=2048 ; Reduce event task stack
|
||||||
|
-DTCPIP_TASK_STACK_SIZE=2048 ; Reduce TCP/IP stack
|
||||||
|
-DESP_TASK_WDT_TIMEOUT_S=10 ; Reduce watchdog timeout
|
||||||
|
-DCONFIG_ASYNC_TCP_MAX_ACK_TIME=3000 ; Reduce TCP ACK timeout
|
||||||
|
-DCONFIG_ASYNC_TCP_QUEUE_SIZE=32 ; Reduce TCP queue size
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Priority
|
||||||
|
|
||||||
|
1. **IMMEDIATE (High Impact)**:
|
||||||
|
- ESPAsyncWifiManager configuration optimization
|
||||||
|
- File upload handler memory optimization
|
||||||
|
- Enhanced build flags
|
||||||
|
|
||||||
|
2. **SHORT TERM (Medium Impact)**:
|
||||||
|
- JSON buffer optimization with F() macros
|
||||||
|
- Web request handler optimization
|
||||||
|
- Static content serving optimization
|
||||||
|
|
||||||
|
3. **LONG TERM (Maintenance)**:
|
||||||
|
- Monitor memory usage patterns
|
||||||
|
- Consider implementing request queuing if needed
|
||||||
|
- Profile actual memory usage during web operations
|
||||||
|
|
||||||
|
## Expected Total Memory Savings
|
||||||
|
|
||||||
|
| Optimization Category | Memory Saved |
|
||||||
|
|----------------------|--------------|
|
||||||
|
| ESPAsyncWifiManager Config | 15-25KB |
|
||||||
|
| File Upload Handler | 15-30KB |
|
||||||
|
| JSON & String Optimizations | 5-10KB |
|
||||||
|
| Build Flag Optimizations | 5-10KB |
|
||||||
|
| **Total Potential Savings** | **40-75KB** |
|
||||||
|
|
||||||
|
## Testing & Validation
|
||||||
|
|
||||||
|
After implementing these optimizations:
|
||||||
|
|
||||||
|
1. Monitor free heap via web interface during:
|
||||||
|
- WiFi connection process
|
||||||
|
- File uploads
|
||||||
|
- Multiple concurrent web requests
|
||||||
|
- JSON state requests
|
||||||
|
|
||||||
|
2. Test stability under load:
|
||||||
|
- Multiple rapid web requests
|
||||||
|
- Large file uploads
|
||||||
|
- WiFi reconnection scenarios
|
||||||
|
|
||||||
|
3. Verify functionality:
|
||||||
|
- All web endpoints work correctly
|
||||||
|
- File uploads complete successfully
|
||||||
|
- WiFi manager portal functions properly
|
||||||
59
SD_CARD_SETUP.md
Normal file
59
SD_CARD_SETUP.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# SD Card Setup Instructions for HannaBox
|
||||||
|
|
||||||
|
## Required Directory Structure
|
||||||
|
|
||||||
|
Your SD card must have the following directory structure for the HannaBox to work properly:
|
||||||
|
|
||||||
|
```
|
||||||
|
SD Card Root/
|
||||||
|
├── system/
|
||||||
|
│ ├── style.css (Web interface CSS - copy from web/style.css)
|
||||||
|
│ ├── script.js (Web interface JavaScript - copy from web/script.js)
|
||||||
|
| ├── index.html (homepage - copy from web/index.html)
|
||||||
|
│ ├── start.mp3 (Startup sound)
|
||||||
|
│ └── sleep.mp3 (Sleep sound)
|
||||||
|
├── [your music folders]/
|
||||||
|
└── [your music files]/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup Steps
|
||||||
|
|
||||||
|
1. **Format your SD card** (FAT32 recommended)
|
||||||
|
|
||||||
|
2. **Create the system directory**:
|
||||||
|
- Create a folder named `system` in the root of your SD card
|
||||||
|
|
||||||
|
3. **Copy the web interface files**:
|
||||||
|
- Copy the file `web/style.css` from this project to `system/style.css` on your SD card
|
||||||
|
- Copy the file `web/script.js` from this project to `system/script.js` on your SD card
|
||||||
|
- Copy the file `web/index.html` from this project to `system/index.html` on your SD card
|
||||||
|
- These files contain all the styling and functionality for the web interface
|
||||||
|
|
||||||
|
4. **Add sound files** (optional):
|
||||||
|
- Copy `sounds/start.mp3` to `system/start.mp3` on your SD card (startup sound)
|
||||||
|
- Copy `sounds/sleep.mp3` to `system/sleep.mp3` on your SD card (sleep sound)
|
||||||
|
|
||||||
|
5. **Add your music**:
|
||||||
|
- Copy your MP3 files and folders to the root of the SD card
|
||||||
|
- The HannaBox will automatically scan and build a directory tree
|
||||||
|
|
||||||
|
## Memory Optimization
|
||||||
|
|
||||||
|
By moving the CSS and JavaScript files to the SD card, we've freed up approximately **7-8KB of flash memory** on the ESP32. The web interface will now:
|
||||||
|
|
||||||
|
- Serve CSS and JavaScript directly from the SD card
|
||||||
|
- Fall back to minimal functionality if files are missing
|
||||||
|
- Maintain all functionality while using significantly less flash memory
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **Web interface has no styling**: Check that `system/style.css` exists on your SD card
|
||||||
|
- **Web interface not working properly**: Check that `system/script.js` exists on your SD card
|
||||||
|
- **SD card not detected**: Ensure the SD card is properly formatted (FAT32) and inserted
|
||||||
|
- **Files not loading**: Verify the file paths are exactly `/system/style.css` and `/system/script.js` (case sensitive)
|
||||||
|
|
||||||
|
## File Locations
|
||||||
|
|
||||||
|
- **Source files**: `web/style.css` and `web/script.js` (in this project)
|
||||||
|
- **Target locations**: `system/style.css` and `system/script.js` (on SD card)
|
||||||
|
- **Fallbacks**: Minimal CSS and JavaScript if files are missing
|
||||||
244
USER_MANUAL.md
Normal file
244
USER_MANUAL.md
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
# HannaBox – User Manual
|
||||||
|
|
||||||
|
HannaBox is a child‑friendly music player powered by an ESP32. It plays audio files from a microSD card and can start playlists by tapping an RFID figurine. It has three robust buttons for everyday use and an optional web app for managing music and tags over Wi‑Fi.
|
||||||
|
|
||||||
|
This guide explains how to set it up, use it daily, map figurines, and troubleshoot common issues.
|
||||||
|
|
||||||
|
--------------------------------------------------------------------
|
||||||
|
|
||||||
|
## What you need
|
||||||
|
- HannaBox device (with speaker and RFID reader)
|
||||||
|
- microSD card (FAT32, 8–32 GB recommended)
|
||||||
|
- Power source (USB power supply or internal battery charged)
|
||||||
|
- Optional: Home Wi‑Fi (for the web app)
|
||||||
|
- Optional: RFID figurines/cards (13.56 MHz)
|
||||||
|
|
||||||
|
Safety: Keep volume at safe levels for children. Supervise charging. Avoid liquids and drops.
|
||||||
|
|
||||||
|
--------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Quick start (first‑time setup)
|
||||||
|
|
||||||
|
1) Prepare the SD card (FAT32)
|
||||||
|
- Format your microSD card as FAT32 on your computer.
|
||||||
|
|
||||||
|
2) Create required folders and copy files
|
||||||
|
- On the SD card, create a folder named system.
|
||||||
|
- Copy the web files from this project into that folder:
|
||||||
|
- web/index.html → system/index.html
|
||||||
|
- web/style.css → system/style.css
|
||||||
|
- web/script.js → system/script.js
|
||||||
|
- Copy the optional system sounds:
|
||||||
|
- sounds/start.mp3 → system/start.mp3 (plays after successful startup)
|
||||||
|
- sounds/sleep.mp3 → system/sleep.mp3 (plays before going to sleep)
|
||||||
|
|
||||||
|
Resulting SD layout (example):
|
||||||
|
```
|
||||||
|
SD Card Root/
|
||||||
|
├── system/
|
||||||
|
│ ├── index.html
|
||||||
|
│ ├── style.css
|
||||||
|
│ ├── script.js
|
||||||
|
│ ├── start.mp3
|
||||||
|
│ └── sleep.mp3
|
||||||
|
├── Music/ (your folders – optional names)
|
||||||
|
│ ├── AlbumA/
|
||||||
|
│ │ ├── 01.mp3
|
||||||
|
│ │ └── 02.mp3
|
||||||
|
│ └── AlbumB/
|
||||||
|
│ └── story.mp3
|
||||||
|
└── loose_song.mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
3) Insert the SD card and power on the HannaBox
|
||||||
|
- On first start, the device initializes the SD card and loads defaults.
|
||||||
|
- You’ll hear start.mp3 if present.
|
||||||
|
|
||||||
|
4) Connect to Wi‑Fi (optional, for the web app)
|
||||||
|
- If the HannaBox isn’t already on your Wi‑Fi, it opens a temporary hotspot named “HannaBox”.
|
||||||
|
- Connect your phone or laptop to the “HannaBox” Wi‑Fi and follow the captive portal to select your home network and enter its password.
|
||||||
|
- After it connects, note the device’s IP address (shown in the portal or in your router’s device list).
|
||||||
|
|
||||||
|
Tip: The HannaBox works without Wi‑Fi for basic playback and figurines. Wi‑Fi is only needed for the web app (playlist view, uploads, tag mapping from a browser).
|
||||||
|
|
||||||
|
--------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Daily use
|
||||||
|
|
||||||
|
### The three buttons
|
||||||
|
- Start/Stop (Play/Pause)
|
||||||
|
- Short press: Toggle play/pause.
|
||||||
|
- Hold to adjust volume with the other buttons (see below).
|
||||||
|
- Next
|
||||||
|
- Short press while playing: Skip to the next track.
|
||||||
|
- If stopped: increases volume by one step and plays the start sound as feedback.
|
||||||
|
- While holding Start/Stop: increases volume (no feedback sound).
|
||||||
|
- Previous
|
||||||
|
- Short press while playing: Go to the previous track. If the current track played for more than ~2 seconds, it restarts the current track first.
|
||||||
|
- If stopped: decreases volume by one step and plays the start sound as feedback.
|
||||||
|
- While holding Start/Stop: decreases volume (no feedback sound).
|
||||||
|
|
||||||
|
Volume range is 0–15 by default (configurable). Changes made with buttons and the web app are kept in sync.
|
||||||
|
|
||||||
|
### Using RFID figurines
|
||||||
|
- Put a figurine on top
|
||||||
|
- If the figurine has a mapping, the associated song or folder will play according to its mode (see “Mapping figurines” below).
|
||||||
|
- If nothing happens, the figurine may not be mapped yet. Use the web app’s Manager to map it (see next section).
|
||||||
|
|
||||||
|
### Auto‑sleep and resume
|
||||||
|
- After inactivity (defaults to 30 minutes), HannaBox plays system/sleep.mp3 and then goes into deep sleep to save battery.
|
||||||
|
- Press Start/Stop to wake it.
|
||||||
|
- By default it resumes the last track and position where you left off (can be changed in config).
|
||||||
|
|
||||||
|
### Battery behavior
|
||||||
|
- The device monitors battery voltage. If it’s too low, it will prepare to sleep and then go to deep sleep to protect the battery. Charge before use.
|
||||||
|
|
||||||
|
--------------------------------------------------------------------
|
||||||
|
|
||||||
|
## The web app (local control & management)
|
||||||
|
|
||||||
|
Requirements: The HannaBox must be connected to your Wi‑Fi. Open a browser to the HannaBox’s IP address (e.g., http://192.168.x.x). The page and styling are served from the SD card’s system folder.
|
||||||
|
|
||||||
|
Main areas:
|
||||||
|
- Player card: Big Play/Pause, Next, Previous, current title, time bar, and a volume slider.
|
||||||
|
- Playlist: A clickable list of your folders and songs. Click any entry to start it.
|
||||||
|
- Manager (toggle button): Tools for uploads, file management, and RFID mapping.
|
||||||
|
|
||||||
|
What you can do:
|
||||||
|
- See what’s playing and control playback (Play/Pause/Next/Previous).
|
||||||
|
- Seek within a track using the time slider.
|
||||||
|
- Adjust volume (0–15) with the volume slider.
|
||||||
|
- Browse and click items in the playlist to play them.
|
||||||
|
- Toggle Manager for advanced actions.
|
||||||
|
|
||||||
|
Manager features:
|
||||||
|
- Status indicators: Shows battery voltage (mV), last scanned NFC ID, and free memory (for info).
|
||||||
|
- Upload audio: Upload .mp3 (up to ~50 MB). If a file with the same name exists, the box will automatically choose a new name. Ensure your SD card has free space.
|
||||||
|
- Move/Rename files and Delete files.
|
||||||
|
- Refresh playlist view.
|
||||||
|
|
||||||
|
--------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Mapping figurines (RFID → what to play)
|
||||||
|
|
||||||
|
Option A: Map through the web app (recommended)
|
||||||
|
1) Tap the figurine near the reader.
|
||||||
|
2) Open the web app and toggle Manager.
|
||||||
|
3) Copy “Last NFC ID” into the RFID field (it will autofill after tapping).
|
||||||
|
4) In “Song”, enter:
|
||||||
|
- A file name (e.g., story.mp3), or
|
||||||
|
- A folder name (e.g., AlbumA), or
|
||||||
|
- A full path (e.g., /Music/AlbumB/story.mp3)
|
||||||
|
Tip: Using a folder name makes mappings more robust if you later move files.
|
||||||
|
5) Choose Mode:
|
||||||
|
- Single (s): Play just the selected song/file once.
|
||||||
|
- Folder (f): Play all files in the selected folder once, then stop.
|
||||||
|
- Random (r): Play all files in the selected folder in random order once, then stop.
|
||||||
|
- Continuous (c): Loop through the selected folder continuously.
|
||||||
|
6) Click “Update Mapping”.
|
||||||
|
|
||||||
|
Option B: Edit the mapping file on SD (advanced)
|
||||||
|
- File: /system/mapping.txt
|
||||||
|
- Lines look like:
|
||||||
|
UID=target|mode
|
||||||
|
Example:
|
||||||
|
12 34 56 78=AlbumA|f
|
||||||
|
90 87 65 43=/Music/AlbumB/story.mp3|s
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- UID format is four bytes separated by spaces (copied from the web app’s “Last NFC ID”).
|
||||||
|
- If you map to a folder, the player will progress inside that folder. With mode c it loops; with f it stops at the end.
|
||||||
|
|
||||||
|
--------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Supported audio and playlist rules
|
||||||
|
- The on‑device playlist is built from the SD card content (alphabetical).
|
||||||
|
- The web app’s Playlist panel lets you click directories and files to play them.
|
||||||
|
- Keep the system folder named exactly system. Do not put your music inside system.
|
||||||
|
|
||||||
|
--------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Configuration (optional, for adults)
|
||||||
|
|
||||||
|
A configuration file is created automatically on first run:
|
||||||
|
- Location: /system/config.txt
|
||||||
|
- Format: key=value (with comments)
|
||||||
|
|
||||||
|
Common options:
|
||||||
|
- initialVolume (0–21): Startup volume (UI uses 0–15 by default).
|
||||||
|
- maxVolume (1–21): Maximum volume limit for buttons/web app.
|
||||||
|
- sleepDelay (milliseconds): No‑interaction time before deep sleep (default 1800000 = 30 min).
|
||||||
|
- sleepMessageDelay (milliseconds): When to play system/sleep.mp3 before sleep (default ~2 s earlier).
|
||||||
|
- minVoltage (mV): Battery level that triggers a sleep to protect the battery (default 3200).
|
||||||
|
- voltage100Percent (mV): Approximate voltage representing 100% (default 4200).
|
||||||
|
- rfidLoopInterval (ms): How often to check the reader (default 25).
|
||||||
|
- startAtStoredProgress (true/false): Resume from last position after wake/restart.
|
||||||
|
- wifiSSID / wifiPassword: Usually left empty; Wi‑Fi setup is handled by the on‑device “HannaBox” hotspot.
|
||||||
|
|
||||||
|
Edit config.txt on your computer if needed (with the HannaBox powered off) or let the defaults work.
|
||||||
|
|
||||||
|
--------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Power, sleep, and waking
|
||||||
|
- Auto‑sleep: After the sleepDelay with no interaction, the box plays system/sleep.mp3 and then sleeps.
|
||||||
|
- Waking: Press the Start/Stop button to wake.
|
||||||
|
- On wake: You’ll hear system/start.mp3 (if present). If resume is enabled, playback continues where it stopped.
|
||||||
|
|
||||||
|
--------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
No sound
|
||||||
|
- Turn volume up: hold Start/Stop and press Next, or use the web app slider.
|
||||||
|
- Try playing a known good .mp3 file from the Playlist.
|
||||||
|
- Ensure speaker is connected and the device is powered.
|
||||||
|
- Confirm system/start.mp3 loads on startup (audible chime).
|
||||||
|
|
||||||
|
Web app looks unstyled or shows errors for style/script
|
||||||
|
- Ensure the SD card has:
|
||||||
|
- /system/index.html
|
||||||
|
- /system/style.css
|
||||||
|
- /system/script.js
|
||||||
|
- Copy the files from this project’s web/ folder to the SD system/ folder (exact names, case sensitive).
|
||||||
|
- Refresh the page.
|
||||||
|
|
||||||
|
Can’t find the device’s web page
|
||||||
|
- Make sure you connected the HannaBox to your home Wi‑Fi using the “HannaBox” hotspot/captive portal.
|
||||||
|
- Check your router’s device list for the IP address and enter it in the browser (e.g., http://192.168.0.25).
|
||||||
|
- Reboot the HannaBox to retrigger the hotspot if it cannot connect to a remembered Wi‑Fi.
|
||||||
|
|
||||||
|
Figurine doesn’t start music
|
||||||
|
- Tap the figurine again and hold it briefly over the reader.
|
||||||
|
- In the web app (Manager), check “Last NFC ID”, then create or update a mapping.
|
||||||
|
- Ensure the target song or folder actually exists on the SD card.
|
||||||
|
|
||||||
|
SD card not detected / empty playlist
|
||||||
|
- SD card must be FAT32 and properly inserted.
|
||||||
|
- Use the required system/ folder and keep music outside system/.
|
||||||
|
- Power‑cycle the HannaBox after changing SD content.
|
||||||
|
|
||||||
|
Device sleeps too quickly
|
||||||
|
- Battery may be low; charge it.
|
||||||
|
- Increase sleepDelay in /system/config.txt if desired.
|
||||||
|
|
||||||
|
Resume didn’t work after wake
|
||||||
|
- Ensure startAtStoredProgress=true in /system/config.txt.
|
||||||
|
- The device writes /system/progress.txt just before sleep; verify the SD card isn’t full.
|
||||||
|
|
||||||
|
Uploads fail or are slow
|
||||||
|
- Ensure there’s at least ~10 MB free on the SD card before uploading.
|
||||||
|
- Keep individual uploads ≤ about 50 MB.
|
||||||
|
- Supported upload types: .mp3
|
||||||
|
|
||||||
|
--------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
- Use folder targets for figurines you want to “play this collection” (e.g., AlbumA with Folder mode).
|
||||||
|
- Use Continuous mode for “bedtime loop” folders.
|
||||||
|
- Keep file and folder names simple (letters, numbers, underscores) to make mapping easier.
|
||||||
|
- Back up your SD card content occasionally.
|
||||||
|
|
||||||
|
--------------------------------------------------------------------
|
||||||
|
|
||||||
|
Enjoy the HannaBox!
|
||||||
|
If you ever need to rebuild the SD card layout, follow the “Quick start” steps above and remember to copy the three web files (index.html, style.css, script.js) into the system folder so the web app looks and works correctly.
|
||||||
43
copy_to_sd.sh
Executable file
43
copy_to_sd.sh
Executable file
@@ -0,0 +1,43 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Script to copy web files to SD card system directory
|
||||||
|
# Make sure your SD card is mounted and accessible
|
||||||
|
|
||||||
|
# Set your SD card mount point here (adjust as needed)
|
||||||
|
SD_MOUNT_POINT="/media/$USER/SD_CARD" # Common Linux mount point
|
||||||
|
# Alternative mount points you might need to try:
|
||||||
|
# SD_MOUNT_POINT="/mnt/sd"
|
||||||
|
# SD_MOUNT_POINT="/media/sd"
|
||||||
|
|
||||||
|
echo "Copying web files to SD card system directory..."
|
||||||
|
|
||||||
|
# Check if SD card is mounted
|
||||||
|
if [ ! -d "$SD_MOUNT_POINT" ]; then
|
||||||
|
echo "Error: SD card not found at $SD_MOUNT_POINT"
|
||||||
|
echo "Please adjust SD_MOUNT_POINT in this script to match your SD card mount point"
|
||||||
|
echo "Common mount points:"
|
||||||
|
echo " /media/\$USER/SD_CARD"
|
||||||
|
echo " /mnt/sd"
|
||||||
|
echo " /media/sd"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create system directory if it doesn't exist
|
||||||
|
mkdir -p "$SD_MOUNT_POINT/system"
|
||||||
|
|
||||||
|
# Copy files
|
||||||
|
echo "Copying style.css..."
|
||||||
|
cp web/style.css "$SD_MOUNT_POINT/system/"
|
||||||
|
|
||||||
|
echo "Copying script.js..."
|
||||||
|
cp web/script.js "$SD_MOUNT_POINT/system/"
|
||||||
|
|
||||||
|
echo "Copying index.html..."
|
||||||
|
cp web/index.html "$SD_MOUNT_POINT/system/"
|
||||||
|
|
||||||
|
echo "Files copied successfully!"
|
||||||
|
echo "Your SD card system directory now contains:"
|
||||||
|
ls -la "$SD_MOUNT_POINT/system/"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Memory optimization complete! The web files are now served from SD card instead of RAM."
|
||||||
26
example_config.txt
Normal file
26
example_config.txt
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# HannaBox Configuration File
|
||||||
|
# Values are in the format: key=value
|
||||||
|
# Lines starting with # are comments
|
||||||
|
|
||||||
|
# Audio Settings
|
||||||
|
initialVolume=7
|
||||||
|
maxVolume=15
|
||||||
|
|
||||||
|
# Power Management (times in milliseconds)
|
||||||
|
sleepTime=1800000
|
||||||
|
sleepDelay=1800000
|
||||||
|
sleepMessageDelay=1798000
|
||||||
|
|
||||||
|
# Battery Settings (voltage in millivolts)
|
||||||
|
minVoltage=3200
|
||||||
|
voltage100Percent=4200
|
||||||
|
|
||||||
|
# RFID Settings
|
||||||
|
rfidLoopInterval=25
|
||||||
|
|
||||||
|
# Playback Settings
|
||||||
|
startAtStoredProgress=true
|
||||||
|
|
||||||
|
# WiFi Settings (leave empty to use current WiFiManager)
|
||||||
|
wifiSSID=
|
||||||
|
wifiPassword=
|
||||||
@@ -4694,18 +4694,9 @@ uint32_t Audio::getAudioCurrentTime() { // return current time in seconds
|
|||||||
}
|
}
|
||||||
//------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
//------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||||
bool Audio::setAudioPlayPosition(uint16_t sec) {
|
bool Audio::setAudioPlayPosition(uint16_t sec) {
|
||||||
if(!m_f_psramFound) { log_w("PSRAM must be activated"); return false;} // guard
|
|
||||||
if(m_dataMode != AUDIO_LOCALFILE /* && m_streamType == ST_WEBFILE */) return false; // guard
|
|
||||||
if(!m_avr_bitrate) return false; // guard
|
|
||||||
//if(m_codec == CODEC_OPUS) return false; // not impl. yet
|
|
||||||
//if(m_codec == CODEC_VORBIS) return false; // not impl. yet
|
|
||||||
// Jump to an absolute position in time within an audio file
|
|
||||||
// e.g. setAudioPlayPosition(300) sets the pointer at pos 5 min
|
|
||||||
if(sec > getAudioFileDuration()) sec = getAudioFileDuration();
|
if(sec > getAudioFileDuration()) sec = getAudioFileDuration();
|
||||||
uint32_t filepos = m_audioDataStart + (m_avr_bitrate * sec / 8);
|
uint32_t filepos = m_audioDataStart + (m_avr_bitrate * sec / 8);
|
||||||
if(m_dataMode == AUDIO_LOCALFILE) return setFilePos(filepos);
|
return setFilePos(filepos);
|
||||||
// if(m_streamType == ST_WEBFILE) return httpRange(m_lastHost, filepos);
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
//------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
//------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||||
void Audio::setVolumeSteps(uint8_t steps) {
|
void Audio::setVolumeSteps(uint8_t steps) {
|
||||||
@@ -4737,23 +4728,15 @@ bool Audio::setTimeOffset(int sec) { // fast forward or rewind the current posit
|
|||||||
}
|
}
|
||||||
//------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
//------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||||
bool Audio::setFilePos(uint32_t pos) {
|
bool Audio::setFilePos(uint32_t pos) {
|
||||||
if(!m_f_psramFound) { log_w("PSRAM must be activated"); return false;} // guard
|
if(m_codec == CODEC_OPUS) return false; // not impl. yet
|
||||||
if(m_dataMode != AUDIO_LOCALFILE /* && m_streamType == ST_WEBFILE */) return false; // guard
|
if(m_codec == CODEC_VORBIS) return false; // not impl. yet
|
||||||
if(m_dataMode == AUDIO_LOCALFILE && !audiofile) return false; // guard
|
if(!audiofile) return false;
|
||||||
if(m_codec == CODEC_AAC) return false; // guard, not impl. yet
|
if(pos < m_audioDataStart) pos = m_audioDataStart; // issue #96
|
||||||
|
if(pos > m_fileSize) pos = m_fileSize;
|
||||||
uint32_t startAB = m_audioDataStart; // audioblock begin
|
m_resumeFilePos = pos;
|
||||||
uint32_t endAB = m_audioDataStart + m_audioDataSize; // audioblock end
|
memset(m_outBuff, 0, 2048 * 2 * sizeof(int16_t));
|
||||||
if(pos < (int32_t)startAB) {pos = startAB;}
|
|
||||||
if(pos >= (int32_t)endAB) {pos = endAB;}
|
|
||||||
|
|
||||||
|
|
||||||
m_validSamples = 0;
|
m_validSamples = 0;
|
||||||
if(m_dataMode == AUDIO_LOCALFILE /* || m_streamType == ST_WEBFILE */) {
|
return true;
|
||||||
m_resumeFilePos = pos; // used in processLocalFile()
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
//------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
//------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||||
bool Audio::setSampleRate(uint32_t sampRate) {
|
bool Audio::setSampleRate(uint32_t sampRate) {
|
||||||
|
|||||||
@@ -13,10 +13,24 @@ platform = https://github.com/pioarduino/platform-espressif32/releases/download/
|
|||||||
board = wemos_d1_mini32
|
board = wemos_d1_mini32
|
||||||
framework = arduino
|
framework = arduino
|
||||||
lib_deps =
|
lib_deps =
|
||||||
ESP32Async/AsyncTCP@3.3.8
|
ESP32Async/ESPAsyncWebServer@3.9.2
|
||||||
ESP32Async/ESPAsyncWebServer@3.7.9
|
|
||||||
alanswx/ESPAsyncWiFiManager@0.31
|
alanswx/ESPAsyncWiFiManager@0.31
|
||||||
miguelbalboa/MFRC522@^1.4.12
|
miguelbalboa/MFRC522@^1.4.12
|
||||||
bblanchon/ArduinoJson@^6.21.3
|
|
||||||
monitor_speed = 115200
|
monitor_speed = 115200
|
||||||
|
build_flags =
|
||||||
|
-Os ; Optimize for size
|
||||||
|
-DDEBUG ; Hannabox Debugging
|
||||||
|
; -DCORE_DEBUG_LEVEL=0 ; Disable all debug output
|
||||||
|
; -DARDUINO_LOOP_STACK_SIZE=4096 ; Balanced to avoid stack canary without starving heap
|
||||||
|
; -DWIFI_TASK_STACK_SIZE=3072 ; Reduce WiFi task stack
|
||||||
|
; -DARDUINO_EVENT_TASK_STACK_SIZE=2048 ; Reduce event task stack
|
||||||
|
; -DTCPIP_TASK_STACK_SIZE=2048 ; Reduce TCP/IP stack
|
||||||
|
; -DESP_TASK_WDT_TIMEOUT_S=10 ; Reduce watchdog timeout
|
||||||
|
-DCONFIG_ASYNC_TCP_MAX_ACK_TIME=5000 ; (keep default)
|
||||||
|
-DCONFIG_ASYNC_TCP_PRIORITY=10 ; (keep default)
|
||||||
|
-DCONFIG_ASYNC_TCP_QUEUE_SIZE=64 ;(keep default)
|
||||||
|
-DCONFIG_ASYNC_TCP_RUNNING_CORE=1 ;force async_tcp task to be on same core as Arduino app (default is any core)
|
||||||
|
-DCONFIG_ASYNC_TCP_STACK_SIZE=4096 ; reduce AsyncTCP task stack (default is 16k)
|
||||||
|
|
||||||
|
monitor_filters = esp32_exception_decoder
|
||||||
board_build.partitions = huge_app.csv
|
board_build.partitions = huge_app.csv
|
||||||
|
|||||||
275
scripts/minify_web.py
Normal file
275
scripts/minify_web.py
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Simple build tool to minify web assets in the `web/` folder and generate gzipped versions.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/minify_web.py
|
||||||
|
|
||||||
|
This script will read:
|
||||||
|
- web/index.html
|
||||||
|
- web/style.css
|
||||||
|
- web/script.js
|
||||||
|
|
||||||
|
and write minified outputs to:
|
||||||
|
- web/cleaned/index.html
|
||||||
|
- web/cleaned/style.css
|
||||||
|
- web/cleaned/script.js
|
||||||
|
|
||||||
|
Additionally, gzipped variants are produced to enable efficient on-device serving:
|
||||||
|
- web/cleaned/index.html.gz
|
||||||
|
- web/cleaned/style.css.gz
|
||||||
|
- web/cleaned/script.js.gz
|
||||||
|
|
||||||
|
The minifiers are intentionally conservative (no external deps) and aim to be
|
||||||
|
safe for typical static files used in this project. They remove comments,
|
||||||
|
collapse unnecessary whitespace and do small syntax-preserving transformations.
|
||||||
|
They are NOT as powerful as terser/clean-css/html-minifier but avoid external
|
||||||
|
package installs which may not be available on the build host.
|
||||||
|
|
||||||
|
If you want stronger/min-safe minification later, replace this script with an
|
||||||
|
npm-based toolchain (npx terser, html-minifier-terser, clean-css) or call those
|
||||||
|
tools from a Makefile.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import gzip
|
||||||
|
|
||||||
|
BASE = Path(__file__).resolve().parent.parent
|
||||||
|
WEB = BASE / "web"
|
||||||
|
CLEAN = WEB / "cleaned"
|
||||||
|
|
||||||
|
def ensure_clean_dir():
|
||||||
|
CLEAN.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# ----------------------
|
||||||
|
# HTML minifier
|
||||||
|
# ----------------------
|
||||||
|
def minify_html(src: str) -> str:
|
||||||
|
"""
|
||||||
|
- Preserve content inside <script>, <style>, <pre>, <code> tags by masking them.
|
||||||
|
- Remove HTML comments.
|
||||||
|
- Collapse whitespace between tags.
|
||||||
|
- Trim leading/trailing whitespace.
|
||||||
|
"""
|
||||||
|
# Mask blocks we don't want to touch
|
||||||
|
pattern = re.compile(r'(?is)<(script|style|pre|code)(\b[^>]*)?>(.*?)</\1>')
|
||||||
|
placeholders = []
|
||||||
|
def _mask(m):
|
||||||
|
placeholders.append(m.group(0))
|
||||||
|
return f"__HTML_PLACEHOLDER_{len(placeholders)-1}__"
|
||||||
|
masked = pattern.sub(_mask, src)
|
||||||
|
|
||||||
|
# Remove comments <!-- ... -->
|
||||||
|
masked = re.sub(r'(?is)<!--.*?-->', '', masked)
|
||||||
|
|
||||||
|
# Collapse whitespace between tags: > < => ><
|
||||||
|
masked = re.sub(r'>\s+<', '><', masked)
|
||||||
|
|
||||||
|
# Collapse multiple spaces to one
|
||||||
|
masked = re.sub(r'[ \t]{2,}', ' ', masked)
|
||||||
|
|
||||||
|
# Remove leading/trailing whitespace/newlines
|
||||||
|
masked = masked.strip()
|
||||||
|
|
||||||
|
# Re-insert placeholders (unchanged)
|
||||||
|
def _restore(m):
|
||||||
|
idx = int(m.group(1))
|
||||||
|
return placeholders[idx]
|
||||||
|
result = re.sub(r'__HTML_PLACEHOLDER_(\d+)__', _restore, masked)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ----------------------
|
||||||
|
# CSS minifier
|
||||||
|
# ----------------------
|
||||||
|
def minify_css(src: str) -> str:
|
||||||
|
"""
|
||||||
|
- Remove comments (/* ... */)
|
||||||
|
- Remove unnecessary whitespace
|
||||||
|
- Collapse semicolons & spaces where safe
|
||||||
|
"""
|
||||||
|
# Remove comments
|
||||||
|
s = re.sub(r'(?s)/\*.*?\*/', '', src)
|
||||||
|
|
||||||
|
# Remove whitespace around symbols
|
||||||
|
s = re.sub(r'\s*([{}:;,])\s*', r'\1', s)
|
||||||
|
|
||||||
|
# Collapse multiple semicolons
|
||||||
|
s = re.sub(r';;+', ';', s)
|
||||||
|
|
||||||
|
# Remove trailing semicolon before closing brace
|
||||||
|
s = re.sub(r';}', '}', s)
|
||||||
|
|
||||||
|
# Collapse multiple whitespace/newlines
|
||||||
|
s = re.sub(r'\s+', ' ', s)
|
||||||
|
|
||||||
|
return s.strip()
|
||||||
|
|
||||||
|
# ----------------------
|
||||||
|
# JS minifier (simple, conservative)
|
||||||
|
# ----------------------
|
||||||
|
def minify_js(src: str) -> str:
|
||||||
|
"""
|
||||||
|
More conservative JS minifier:
|
||||||
|
|
||||||
|
- Removes /* ... */ block comments that are not inside strings or template literals.
|
||||||
|
- Does NOT remove // line comments (they can be significant in JS and in regexes/URLs).
|
||||||
|
- Trims trailing spaces on each line and collapses multiple empty lines to a single newline.
|
||||||
|
- Preserves all other whitespace and token boundaries to avoid introducing syntax errors.
|
||||||
|
This approach is intentionally conservative to avoid unexpected tokens.
|
||||||
|
"""
|
||||||
|
out_chars = []
|
||||||
|
i = 0
|
||||||
|
L = len(src)
|
||||||
|
in_squote = False
|
||||||
|
in_dquote = False
|
||||||
|
in_bquote = False
|
||||||
|
esc = False
|
||||||
|
in_block_comment = False
|
||||||
|
|
||||||
|
# First pass: remove /* ... */ block comments but only when not inside strings/templates
|
||||||
|
while i < L:
|
||||||
|
c = src[i]
|
||||||
|
nxt = src[i+1] if i+1 < L else ''
|
||||||
|
|
||||||
|
if in_block_comment:
|
||||||
|
if c == '*' and nxt == '/':
|
||||||
|
in_block_comment = False
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Handle string/template entry/exit
|
||||||
|
if c == "'" and not (in_dquote or in_bquote):
|
||||||
|
if not esc:
|
||||||
|
in_squote = not in_squote
|
||||||
|
out_chars.append(c)
|
||||||
|
esc = False
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
if c == '"' and not (in_squote or in_bquote):
|
||||||
|
if not esc:
|
||||||
|
in_dquote = not in_dquote
|
||||||
|
out_chars.append(c)
|
||||||
|
esc = False
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
if c == '`' and not (in_squote or in_dquote):
|
||||||
|
if not esc:
|
||||||
|
in_bquote = not in_bquote
|
||||||
|
out_chars.append(c)
|
||||||
|
esc = False
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Escape handling inside strings/templates
|
||||||
|
if (in_squote or in_dquote or in_bquote) and c == '\\' and not esc:
|
||||||
|
esc = True
|
||||||
|
out_chars.append(c)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
if esc:
|
||||||
|
out_chars.append(c)
|
||||||
|
esc = False
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Detect block comment only when not inside a string/template
|
||||||
|
if not (in_squote or in_dquote or in_bquote) and c == '/' and nxt == '*':
|
||||||
|
in_block_comment = True
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Otherwise keep character
|
||||||
|
out_chars.append(c)
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
code_no_block_comments = ''.join(out_chars)
|
||||||
|
|
||||||
|
# Second pass: line-wise trimming and blank-line collapse (very conservative)
|
||||||
|
lines = code_no_block_comments.splitlines()
|
||||||
|
trimmed_lines = []
|
||||||
|
prev_blank = False
|
||||||
|
for line in lines:
|
||||||
|
# remove trailing whitespace only
|
||||||
|
t = line.rstrip()
|
||||||
|
if t == '':
|
||||||
|
if not prev_blank:
|
||||||
|
trimmed_lines.append('')
|
||||||
|
prev_blank = True
|
||||||
|
else:
|
||||||
|
trimmed_lines.append(t)
|
||||||
|
prev_blank = False
|
||||||
|
|
||||||
|
result = '\n'.join(trimmed_lines).strip() + '\n' if trimmed_lines else ''
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ----------------------
|
||||||
|
# File utilities
|
||||||
|
# ----------------------
|
||||||
|
def read_file(path: Path) -> str:
|
||||||
|
try:
|
||||||
|
return path.read_text(encoding='utf-8')
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ERROR reading {path}: {e}", file=sys.stderr)
|
||||||
|
return ''
|
||||||
|
|
||||||
|
def write_file(path: Path, data: str):
|
||||||
|
try:
|
||||||
|
path.write_text(data, encoding='utf-8')
|
||||||
|
print(f"Wrote {path} ({len(data)} bytes)")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ERROR writing {path}: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
def write_gzip(path: Path, data: str):
|
||||||
|
"""Write UTF-8 text as gzip with deterministic mtime for reproducible builds."""
|
||||||
|
try:
|
||||||
|
gz_bytes = gzip.compress(data.encode('utf-8'), mtime=0)
|
||||||
|
path.write_bytes(gz_bytes)
|
||||||
|
print(f"Wrote {path} ({len(gz_bytes)} bytes, gzip)")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ERROR writing {path}: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
def minify_all():
|
||||||
|
ensure_clean_dir()
|
||||||
|
|
||||||
|
# HTML
|
||||||
|
index = WEB / "index.html"
|
||||||
|
if index.exists():
|
||||||
|
print("Minifying HTML:", index)
|
||||||
|
s = read_file(index)
|
||||||
|
out = minify_html(s)
|
||||||
|
write_file(CLEAN / "index.html", out)
|
||||||
|
write_gzip(CLEAN / "index.html.gz", out)
|
||||||
|
else:
|
||||||
|
print("No index.html found in web/")
|
||||||
|
|
||||||
|
# CSS
|
||||||
|
css = WEB / "style.css"
|
||||||
|
if css.exists():
|
||||||
|
print("Minifying CSS:", css)
|
||||||
|
s = read_file(css)
|
||||||
|
out = minify_css(s)
|
||||||
|
write_file(CLEAN / "style.css", out)
|
||||||
|
write_gzip(CLEAN / "style.css.gz", out)
|
||||||
|
else:
|
||||||
|
print("No style.css found in web/")
|
||||||
|
|
||||||
|
# JS
|
||||||
|
js = WEB / "script.js"
|
||||||
|
if js.exists():
|
||||||
|
print("Minifying JS:", js)
|
||||||
|
s = read_file(js)
|
||||||
|
out = minify_js(s)
|
||||||
|
write_file(CLEAN / "script.js", out)
|
||||||
|
write_gzip(CLEAN / "script.js.gz", out)
|
||||||
|
else:
|
||||||
|
print("No script.js found in web/")
|
||||||
|
|
||||||
|
print("Minification complete. Output placed in", CLEAN)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
minify_all()
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
#include "DirectoryNode.h"
|
#include "DirectoryNode.h"
|
||||||
|
#include "globals.h"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstring> // strlen, strlcpy, strlcat
|
||||||
|
#include <strings.h> // strcasecmp
|
||||||
|
|
||||||
|
char DirectoryNode::buffer[DirectoryNode::buffer_size];
|
||||||
|
|
||||||
DirectoryNode::DirectoryNode(const String &nodeName)
|
DirectoryNode::DirectoryNode(const String &nodeName)
|
||||||
: name(nodeName), currentPlaying(nullptr)
|
: name(nodeName), currentPlaying("")
|
||||||
{
|
{
|
||||||
id = DirectoryNode::idCounter;
|
id = DirectoryNode::idCounter;
|
||||||
DirectoryNode::idCounter++;
|
DirectoryNode::idCounter++;
|
||||||
@@ -35,19 +41,86 @@ const std::vector<String> &DirectoryNode::getMP3Files() const
|
|||||||
return mp3Files;
|
return mp3Files;
|
||||||
}
|
}
|
||||||
|
|
||||||
void DirectoryNode::setCurrentPlaying(const String *mp3File)
|
const String &DirectoryNode::getDirPath() const
|
||||||
{
|
{
|
||||||
currentPlaying = mp3File;
|
return dirPath;
|
||||||
for (int i = 0; i < mp3Files.size(); i++)
|
}
|
||||||
|
|
||||||
|
uint16_t DirectoryNode::getFileIdAt(size_t i) const
|
||||||
|
{
|
||||||
|
return (i < ids.size()) ? ids[i] : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
String DirectoryNode::buildFullPath(const String &fileName) const
|
||||||
|
{
|
||||||
|
if (dirPath == "/")
|
||||||
{
|
{
|
||||||
if (mp3Files[i] == *mp3File && ids.size() > i)
|
String p = "/";
|
||||||
|
p += fileName;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
String p = dirPath;
|
||||||
|
p += "/";
|
||||||
|
p += fileName;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DirectoryNode::comparePathWithString(const char* path, const String& target) const
|
||||||
|
{
|
||||||
|
// Convert target to char* for comparison
|
||||||
|
const char* targetStr = target.c_str();
|
||||||
|
|
||||||
|
// Case-insensitive string comparison
|
||||||
|
return strcasecmp(path, targetStr) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void DirectoryNode::buildFullPath(const String &fileName, char* out, size_t n) const
|
||||||
|
{
|
||||||
|
if (n == 0) return;
|
||||||
|
out[0] = '\0';
|
||||||
|
|
||||||
|
if (dirPath == "/")
|
||||||
|
{
|
||||||
|
strlcat(out, "/", n);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
strlcpy(out, dirPath.c_str(), n);
|
||||||
|
strlcat(out, "/", n);
|
||||||
|
}
|
||||||
|
|
||||||
|
strlcat(out, fileName.c_str(), n);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DirectoryNode::setCurrentPlaying(const String &mp3File)
|
||||||
|
{
|
||||||
|
bool isAbs = (mp3File.length() > 0) && (mp3File.charAt(0) == '/');
|
||||||
|
const String &fileName = isAbs ? mp3File.substring(mp3File.lastIndexOf('/') + 1) : mp3File;
|
||||||
|
|
||||||
|
if (isAbs)
|
||||||
|
{
|
||||||
|
currentPlaying = mp3File; // Already absolute path
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Use buffer for building relative path
|
||||||
|
buildFullPath(fileName, buffer, buffer_size);
|
||||||
|
currentPlaying = String(buffer); // Convert back to String for assignment
|
||||||
|
}
|
||||||
|
|
||||||
|
for (size_t i = 0; i < mp3Files.size(); i++)
|
||||||
|
{
|
||||||
|
if (mp3Files[i] == fileName && ids.size() > i)
|
||||||
{
|
{
|
||||||
currentPlayingId = ids[i];
|
currentPlayingId = ids[i];
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const String *DirectoryNode::getCurrentPlaying() const
|
const String &DirectoryNode::getCurrentPlaying() const
|
||||||
{
|
{
|
||||||
return currentPlaying;
|
return currentPlaying;
|
||||||
}
|
}
|
||||||
@@ -80,7 +153,7 @@ void DirectoryNode::setSecondsPlayed(const uint32_t seconds)
|
|||||||
secondsPlayed = seconds;
|
secondsPlayed = seconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
uint32_t DirectoryNode::getSecondsPlayed()
|
uint32_t DirectoryNode::getSecondsPlayed() const
|
||||||
{
|
{
|
||||||
return secondsPlayed;
|
return secondsPlayed;
|
||||||
}
|
}
|
||||||
@@ -95,8 +168,26 @@ void DirectoryNode::buildDirectoryTree(const char *currentPath)
|
|||||||
subdirectories.clear();
|
subdirectories.clear();
|
||||||
mp3Files.clear();
|
mp3Files.clear();
|
||||||
ids.clear();
|
ids.clear();
|
||||||
|
|
||||||
|
// Set directory path for this node (normalize: keep "/" or remove trailing slash)
|
||||||
|
String path = String(currentPath);
|
||||||
|
if (path.length() > 1 && path.endsWith("/"))
|
||||||
|
{
|
||||||
|
path.remove(path.length() - 1);
|
||||||
|
}
|
||||||
|
dirPath = path;
|
||||||
|
|
||||||
|
// First collect entries so we can sort them alphabetically
|
||||||
|
std::vector<String> dirNames;
|
||||||
|
std::vector<String> fileNames;
|
||||||
|
|
||||||
File rootDir = SD.open(currentPath);
|
File rootDir = SD.open(currentPath);
|
||||||
|
if (!rootDir)
|
||||||
|
{
|
||||||
|
Serial.print(F("buildDirectoryTree: failed to open path: "));
|
||||||
|
Serial.println(currentPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
File entry = rootDir.openNextFile();
|
File entry = rootDir.openNextFile();
|
||||||
@@ -105,39 +196,105 @@ void DirectoryNode::buildDirectoryTree(const char *currentPath)
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.isDirectory() && entry.name()[0] != '.' && strcmp(entry.name(), sys_dir.c_str()))
|
if (entry.isDirectory() && entry.name()[0] != '.' && strcmp(entry.name(), sys_dir) != 0)
|
||||||
{
|
{
|
||||||
DirectoryNode *newNode = new DirectoryNode(entry.name());
|
dirNames.emplace_back(entry.name());
|
||||||
subdirectories.push_back(newNode);
|
|
||||||
newNode->buildDirectoryTree((String(currentPath) + entry.name()).c_str());
|
|
||||||
}
|
}
|
||||||
else if (String(entry.name()).endsWith(".mp3")||String(entry.name()).endsWith(".MP3"))
|
else
|
||||||
{
|
{
|
||||||
mp3Files.push_back(entry.name());
|
String entryName = entry.name();
|
||||||
ids.push_back(getNextId());
|
if (entryName.endsWith(".mp3") || entryName.endsWith(".MP3"))
|
||||||
|
{
|
||||||
|
fileNames.push_back(std::move(entryName));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
entry.close();
|
entry.close();
|
||||||
}
|
}
|
||||||
rootDir.close();
|
rootDir.close();
|
||||||
|
|
||||||
|
// Case-insensitive alphabetical sort without allocations
|
||||||
|
auto ciLess = [](const String &a, const String &b) {
|
||||||
|
const char* pa = a.c_str();
|
||||||
|
const char* pb = b.c_str();
|
||||||
|
while (*pa && *pb) {
|
||||||
|
char ca = *pa++;
|
||||||
|
char cb = *pb++;
|
||||||
|
if (ca >= 'A' && ca <= 'Z') ca += 'a' - 'A';
|
||||||
|
if (cb >= 'A' && cb <= 'Z') cb += 'a' - 'A';
|
||||||
|
if (ca < cb) return true;
|
||||||
|
if (ca > cb) return false;
|
||||||
|
}
|
||||||
|
return *pa < *pb;
|
||||||
|
};
|
||||||
|
std::sort(dirNames.begin(), dirNames.end(), ciLess);
|
||||||
|
std::sort(fileNames.begin(), fileNames.end(), ciLess);
|
||||||
|
|
||||||
|
// Reserve memory to reduce heap fragmentation
|
||||||
|
subdirectories.reserve(dirNames.size());
|
||||||
|
mp3Files.reserve(fileNames.size());
|
||||||
|
ids.reserve(fileNames.size());
|
||||||
|
|
||||||
|
// Add MP3 files in alphabetical order first to free fileNames memory before recursing
|
||||||
|
for (const String &fileName : fileNames)
|
||||||
|
{
|
||||||
|
mp3Files.push_back(fileName);
|
||||||
|
ids.push_back(getNextId());
|
||||||
|
}
|
||||||
|
// Free memory used by fileNames vector
|
||||||
|
{
|
||||||
|
std::vector<String> empty;
|
||||||
|
fileNames.swap(empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create subdirectories in alphabetical order
|
||||||
|
// Use index loop and std::move to free strings in dirNames as we go, reducing stack memory usage during recursion
|
||||||
|
for (size_t i = 0; i < dirNames.size(); ++i)
|
||||||
|
{
|
||||||
|
String dirName = std::move(dirNames[i]); // Move string content out of vector
|
||||||
|
|
||||||
|
DirectoryNode *newNode = new DirectoryNode(dirName);
|
||||||
|
if (!newNode)
|
||||||
|
{
|
||||||
|
Serial.println(F("buildDirectoryTree: OOM creating DirectoryNode"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
subdirectories.push_back(newNode);
|
||||||
|
|
||||||
|
String childPath;
|
||||||
|
childPath.reserve(dirPath.length() + 1 + dirName.length());
|
||||||
|
if (dirPath == "/")
|
||||||
|
{
|
||||||
|
childPath = "/";
|
||||||
|
childPath += dirName;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
childPath = dirPath;
|
||||||
|
childPath += "/";
|
||||||
|
childPath += dirName;
|
||||||
|
}
|
||||||
|
|
||||||
|
newNode->buildDirectoryTree(childPath.c_str());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void DirectoryNode::printDirectoryTree(int level) const
|
void DirectoryNode::printDirectoryTree(int level) const
|
||||||
{
|
{
|
||||||
for (int i = 0; i < level; i++)
|
for (int i = 0; i < level; i++)
|
||||||
{
|
{
|
||||||
Serial.print(" ");
|
Serial.print(F(" "));
|
||||||
}
|
}
|
||||||
Serial.println(name);
|
Serial.println(name);
|
||||||
|
|
||||||
for (const String &mp3File : mp3Files)
|
for (const String &mp3File : mp3Files)
|
||||||
{
|
{
|
||||||
for (int i = 0; i <= level; i++)
|
for (int i = 0; i <= level; i++)
|
||||||
{
|
{
|
||||||
Serial.print(" ");
|
Serial.print(F(" "));
|
||||||
}
|
}
|
||||||
Serial.println(mp3File);
|
Serial.println(mp3File);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (DirectoryNode *childNode : subdirectories)
|
for (DirectoryNode *childNode : subdirectories)
|
||||||
{
|
{
|
||||||
childNode->printDirectoryTree(level + 1);
|
childNode->printDirectoryTree(level + 1);
|
||||||
@@ -170,81 +327,152 @@ void DirectoryNode::advanceToFirstMP3InThisNode()
|
|||||||
{
|
{
|
||||||
if (mp3Files.size() > 0)
|
if (mp3Files.size() > 0)
|
||||||
{
|
{
|
||||||
setCurrentPlaying(&mp3Files[0]);
|
setCurrentPlaying(mp3Files[0]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DirectoryNode *DirectoryNode::advanceToMP3(const uint16_t id)
|
DirectoryNode *DirectoryNode::advanceToMP3(const uint16_t id)
|
||||||
{
|
{
|
||||||
|
// First check MP3 files in this directory
|
||||||
|
|
||||||
for (size_t i = 0; i < ids.size(); i++)
|
for (size_t i = 0; i < ids.size(); i++)
|
||||||
|
{
|
||||||
|
if (id == ids[i])
|
||||||
{
|
{
|
||||||
if (id == ids[i])
|
// Found the current MP3 file
|
||||||
{
|
buildFullPath(mp3Files[i], buffer, buffer_size);
|
||||||
// Found the current MP3 file
|
currentPlaying = String(buffer); // Convert back to String for assignment
|
||||||
currentPlaying = &mp3Files[i];
|
currentPlayingId = id;
|
||||||
currentPlayingId = id;
|
return this;
|
||||||
return this;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively search subdirectories
|
||||||
for (auto subdir : subdirectories)
|
for (auto subdir : subdirectories)
|
||||||
{
|
{
|
||||||
|
// Check if the ID matches a subdirectory ID
|
||||||
if (subdir->getId() == id)
|
if (subdir->getId() == id)
|
||||||
{
|
{
|
||||||
subdir->advanceToFirstMP3InThisNode();
|
subdir->advanceToFirstMP3InThisNode();
|
||||||
return subdir;
|
return subdir;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Have each subdirectory advance its song
|
// Recursively search in subdirectory
|
||||||
for (size_t i = 0; i < subdir->ids.size(); i++)
|
DirectoryNode *result = subdir->advanceToMP3(id);
|
||||||
|
if (result != nullptr && !result->getCurrentPlaying().isEmpty())
|
||||||
{
|
{
|
||||||
if (id == subdir->ids[i])
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we get here, no song with this ID was found
|
||||||
|
Serial.print(F("advanceToMP3: No song found for ID: "));
|
||||||
|
Serial.println(id);
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
DirectoryNode *DirectoryNode::advanceToMP3(const String &songName)
|
||||||
|
{
|
||||||
|
if (songName.isEmpty())
|
||||||
|
{
|
||||||
|
Serial.println(F("advanceToMP3: songName is empty"));
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the input is an absolute path (starts with '/') or just a filename
|
||||||
|
bool isAbsolutePath = songName.startsWith("/");
|
||||||
|
// Normalize trailing slash for absolute folder path targets
|
||||||
|
String normalizedPath = songName;
|
||||||
|
if (isAbsolutePath && normalizedPath.length() > 1 && normalizedPath.endsWith("/"))
|
||||||
|
{
|
||||||
|
normalizedPath.remove(normalizedPath.length() - 1);
|
||||||
|
}
|
||||||
|
// Lowercased copies for case-insensitive comparisons (FAT can uppercase names)
|
||||||
|
String lowTarget = songName;
|
||||||
|
lowTarget.toLowerCase();
|
||||||
|
|
||||||
|
// First, search in the current directory's MP3 files
|
||||||
|
for (size_t i = 0; i < mp3Files.size(); i++)
|
||||||
|
{
|
||||||
|
if (isAbsolutePath)
|
||||||
|
{
|
||||||
|
// Use static buffer for path building and comparison
|
||||||
|
buildFullPath(mp3Files[i], buffer, buffer_size);
|
||||||
|
if (comparePathWithString(buffer, songName))
|
||||||
{
|
{
|
||||||
// Found the current MP3 file
|
setCurrentPlaying(mp3Files[i]);
|
||||||
subdir->currentPlaying = &subdir->mp3Files[i];
|
return this;
|
||||||
subdir->currentPlayingId = id;
|
}
|
||||||
return subdir;
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Use static buffer for comparison without allocation
|
||||||
|
buildFullPath(mp3Files[i], buffer, buffer_size);
|
||||||
|
size_t bufLen = strlen(buffer);
|
||||||
|
size_t targetLen = lowTarget.length();
|
||||||
|
if (bufLen >= targetLen && strcasecmp(buffer + bufLen - targetLen, lowTarget.c_str()) == 0)
|
||||||
|
{
|
||||||
|
setCurrentPlaying(mp3Files[i]);
|
||||||
|
return this;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we get here, there were no MP3 files or subdirectories left to check
|
// Then search in subdirectories
|
||||||
currentPlaying = nullptr;
|
|
||||||
Serial.println("no more nodes found");
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
DirectoryNode *DirectoryNode::advanceToMP3(const String *currentGlobal)
|
|
||||||
{
|
|
||||||
for (auto subdir : subdirectories)
|
for (auto subdir : subdirectories)
|
||||||
{
|
{
|
||||||
if (subdir->getName() == *currentGlobal)
|
// Absolute folder target: match directory by its full path (dirPath)
|
||||||
|
if (isAbsolutePath)
|
||||||
|
{
|
||||||
|
if (subdir->getDirPath().equalsIgnoreCase(normalizedPath))
|
||||||
|
{
|
||||||
|
subdir->advanceToFirstMP3InThisNode();
|
||||||
|
return subdir;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAbsolutePath && subdir->getName().equalsIgnoreCase(songName))
|
||||||
{
|
{
|
||||||
subdir->advanceToFirstMP3InThisNode();
|
subdir->advanceToFirstMP3InThisNode();
|
||||||
return subdir;
|
return subdir;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Have each subdirectory advance its song
|
// Search all files within subdir:
|
||||||
for (size_t i = 0; i < subdir->mp3Files.size(); i++)
|
for (size_t i = 0; i < subdir->mp3Files.size(); i++)
|
||||||
{
|
{
|
||||||
if (*currentGlobal == subdir->mp3Files[i])
|
|
||||||
|
if (isAbsolutePath)
|
||||||
{
|
{
|
||||||
// Found the current MP3 file
|
if (subdir->buildFullPath(subdir->mp3Files[i]).equalsIgnoreCase(songName))
|
||||||
if (i < subdir->mp3Files.size() - 1)
|
|
||||||
{
|
{
|
||||||
subdir->setCurrentPlaying(&subdir->mp3Files[i]);
|
subdir->setCurrentPlaying(subdir->mp3Files[i]);
|
||||||
|
return subdir;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Check suffix case-insensitively without creating new Strings
|
||||||
|
const String& fName = subdir->mp3Files[i];
|
||||||
|
size_t fLen = fName.length();
|
||||||
|
size_t targetLen = lowTarget.length();
|
||||||
|
if (fLen >= targetLen && strcasecmp(fName.c_str() + fLen - targetLen, lowTarget.c_str()) == 0)
|
||||||
|
{
|
||||||
|
subdir->setCurrentPlaying(subdir->mp3Files[i]);
|
||||||
return subdir;
|
return subdir;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Recurse into deeper subdirectories to support nested folders and files
|
||||||
|
DirectoryNode* deeper = subdir->advanceToMP3(songName);
|
||||||
|
if (deeper != nullptr)
|
||||||
|
{
|
||||||
|
return deeper;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we get here, there were no MP3 files or subdirectories left to check
|
// If we get here, no matching song was found
|
||||||
currentPlaying = nullptr;
|
Serial.print(F("advanceToMP3: No song found for: "));
|
||||||
Serial.println("no more nodes found");
|
Serial.println(songName);
|
||||||
return this;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -257,152 +485,172 @@ DirectoryNode *DirectoryNode::advanceToMP3(const String *currentGlobal)
|
|||||||
*/
|
*/
|
||||||
DirectoryNode *DirectoryNode::goToPreviousMP3(uint32_t thresholdSeconds)
|
DirectoryNode *DirectoryNode::goToPreviousMP3(uint32_t thresholdSeconds)
|
||||||
{
|
{
|
||||||
if (secondsPlayed > thresholdSeconds || currentPlaying == nullptr)
|
// Safety check for null pointer
|
||||||
|
if (currentPlaying.isEmpty())
|
||||||
{
|
{
|
||||||
// Restart the current song if it's been playing for more than thresholdSeconds
|
Serial.println(F("goToPreviousMP3: currentPlaying is empty"));
|
||||||
// Or if there is no current song (at the start of the list)
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we've been playing for more than threshold seconds, restart current song
|
||||||
|
if (secondsPlayed > thresholdSeconds)
|
||||||
|
{
|
||||||
|
Serial.println(F("goToPreviousMP3: Restarting current song (played > threshold)"));
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
// Find the previous song
|
|
||||||
for (size_t i = 0; i < mp3Files.size(); i++)
|
|
||||||
{
|
|
||||||
if (currentPlaying != nullptr && *currentPlaying == mp3Files[i] && i > 0)
|
|
||||||
{
|
|
||||||
// Move to the previous song
|
|
||||||
setCurrentPlaying(&mp3Files[i - 1]);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the first song in the directory or no song was playing, move to the previous directory, if any
|
// Find the current song index in this directory
|
||||||
for (auto subdir : subdirectories)
|
int currentIndex = -1;
|
||||||
|
for (size_t i = 0; i < mp3Files.size(); i++)
|
||||||
|
{
|
||||||
|
buildFullPath(mp3Files[i], buffer, buffer_size);
|
||||||
|
if (comparePathWithString(buffer, currentPlaying))
|
||||||
{
|
{
|
||||||
DirectoryNode *previousNode = subdir->goToPreviousMP3(thresholdSeconds);
|
currentIndex = i;
|
||||||
if (previousNode != nullptr)
|
break;
|
||||||
{
|
|
||||||
return previousNode;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// No previous song available
|
|
||||||
|
// If current song found and not the first song, move to previous
|
||||||
|
if (currentIndex > 0)
|
||||||
|
{
|
||||||
|
Serial.print(F("goToPreviousMP3: Moving to previous song in same directory: "));
|
||||||
|
Serial.println(mp3Files[currentIndex - 1]);
|
||||||
|
setCurrentPlaying(mp3Files[currentIndex - 1]);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're at the first song or song not found in current directory,
|
||||||
|
// we need to find the previous song globally
|
||||||
|
Serial.println(F("goToPreviousMP3: At first song or song not found, looking for previous globally"));
|
||||||
|
return nullptr; // Let the caller handle global previous logic
|
||||||
|
}
|
||||||
|
|
||||||
|
DirectoryNode *DirectoryNode::findPreviousMP3Globally(const String ¤tGlobal)
|
||||||
|
{
|
||||||
|
if (currentGlobal.isEmpty())
|
||||||
|
{
|
||||||
|
Serial.println(F("findPreviousMP3Globally: currentGlobal is null"));
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a flat list of all MP3 files in order
|
||||||
|
std::vector<std::pair<DirectoryNode *, int>> allMP3s;
|
||||||
|
buildFlatMP3List(allMP3s);
|
||||||
|
|
||||||
|
// Find current song in the flat list
|
||||||
|
int currentGlobalIndex = -1;
|
||||||
|
for (size_t i = 0; i < allMP3s.size(); i++)
|
||||||
|
{
|
||||||
|
DirectoryNode *node = allMP3s[i].first;
|
||||||
|
int fileIndex = allMP3s[i].second;
|
||||||
|
|
||||||
|
node->buildFullPath(node->mp3Files[fileIndex], buffer, buffer_size);
|
||||||
|
if (comparePathWithString(buffer, currentGlobal))
|
||||||
|
{
|
||||||
|
currentGlobalIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If current song found and not the first globally, move to previous
|
||||||
|
if (currentGlobalIndex > 0)
|
||||||
|
{
|
||||||
|
DirectoryNode *prevNode = allMP3s[currentGlobalIndex - 1].first;
|
||||||
|
int prevFileIndex = allMP3s[currentGlobalIndex - 1].second;
|
||||||
|
|
||||||
|
prevNode->buildFullPath(prevNode->mp3Files[prevFileIndex], buffer, buffer_size);
|
||||||
|
|
||||||
|
Serial.print(F("findPreviousMP3Globally: Moving to previous song globally: "));
|
||||||
|
Serial.println(buffer);
|
||||||
|
|
||||||
|
prevNode->setCurrentPlaying(prevNode->mp3Files[prevFileIndex]);
|
||||||
|
return prevNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.println(F("findPreviousMP3Globally: No previous song found globally"));
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
DirectoryNode *DirectoryNode::advanceToNextMP3(const String *currentGlobal)
|
void DirectoryNode::buildFlatMP3List(std::vector<std::pair<DirectoryNode *, int>> &allMP3s)
|
||||||
{
|
{
|
||||||
bool useFirst = false;
|
|
||||||
Serial.println(currentGlobal->c_str());
|
#ifdef DEBUG
|
||||||
if (currentGlobal != nullptr)
|
Serial.println("Building flat mp3 list for folder");
|
||||||
|
#endif
|
||||||
|
// Pre-reserve to reduce reallocations
|
||||||
|
allMP3s.reserve(allMP3s.size() + mp3Files.size());
|
||||||
|
// Add all MP3 files from this directory
|
||||||
|
for (size_t i = 0; i < mp3Files.size(); i++)
|
||||||
{
|
{
|
||||||
for (size_t i = 0; i < mp3Files.size(); i++)
|
allMP3s.emplace_back(this, i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively add MP3 files from subdirectories
|
||||||
|
for (DirectoryNode *subdir : subdirectories)
|
||||||
|
{
|
||||||
|
subdir->buildFlatMP3List(allMP3s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t DirectoryNode::getNumOfFiles() const
|
||||||
|
{
|
||||||
|
return subdirectories.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
DirectoryNode *DirectoryNode::advanceToNextMP3(const String ¤tGlobal)
|
||||||
|
{
|
||||||
|
Serial.println(currentGlobal.c_str());
|
||||||
|
|
||||||
|
// Build a flat list of all MP3 files in order to correctly find the next one across directories
|
||||||
|
std::vector<std::pair<DirectoryNode *, int>> allMP3s;
|
||||||
|
buildFlatMP3List(allMP3s);
|
||||||
|
|
||||||
|
if (allMP3s.empty())
|
||||||
|
{
|
||||||
|
Serial.println(F("advanceToNextMP3: No MP3s found in tree"));
|
||||||
|
currentPlaying = "";
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
int currentIndex = -1;
|
||||||
|
if (!currentGlobal.isEmpty())
|
||||||
|
{
|
||||||
|
for (size_t i = 0; i < allMP3s.size(); i++)
|
||||||
{
|
{
|
||||||
if (*currentGlobal == mp3Files[i])
|
DirectoryNode *node = allMP3s[i].first;
|
||||||
|
int fileIndex = allMP3s[i].second;
|
||||||
|
|
||||||
|
node->buildFullPath(node->mp3Files[fileIndex], buffer, buffer_size);
|
||||||
|
if (comparePathWithString(buffer, currentGlobal))
|
||||||
{
|
{
|
||||||
// Found the current playing MP3 file
|
currentIndex = (int)i;
|
||||||
if (i < mp3Files.size() - 1)
|
|
||||||
{
|
|
||||||
// Advance to the next MP3 file in the same directory
|
|
||||||
setCurrentPlaying(&mp3Files[i + 1]);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
useFirst = true;
|
|
||||||
// Reached the end of the MP3 files in the directory
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// We are either not playing, or we've exhausted all the MP3 files in this directory.
|
// If current song found and not the last one, move to next
|
||||||
// Therefore, we need to recursively look in our subdirectories.
|
if (currentIndex >= 0 && currentIndex < (int)allMP3s.size() - 1)
|
||||||
for (auto subdir : subdirectories)
|
|
||||||
{
|
{
|
||||||
|
DirectoryNode *nextNode = allMP3s[currentIndex + 1].first;
|
||||||
if (useFirst && subdir->mp3Files.size() > 0)
|
int nextFileIndex = allMP3s[currentIndex + 1].second;
|
||||||
{
|
|
||||||
subdir->setCurrentPlaying(&subdir->mp3Files[0]);
|
nextNode->setCurrentPlaying(nextNode->mp3Files[nextFileIndex]);
|
||||||
return subdir;
|
return nextNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Have each subdirectory advance its song
|
// If not playing anything (start), play first
|
||||||
for (size_t i = 0; i < subdir->mp3Files.size(); i++)
|
if (currentIndex == -1 && currentGlobal.isEmpty())
|
||||||
{
|
{
|
||||||
if (*currentGlobal == subdir->mp3Files[i])
|
DirectoryNode *nextNode = allMP3s[0].first;
|
||||||
{
|
int nextFileIndex = allMP3s[0].second;
|
||||||
// Found the current playing MP3 file
|
nextNode->setCurrentPlaying(nextNode->mp3Files[nextFileIndex]);
|
||||||
if (i < subdir->mp3Files.size() - 1)
|
return nextNode;
|
||||||
{
|
|
||||||
// Advance to the next MP3 file in the same directory
|
|
||||||
subdir->setCurrentPlaying(&subdir->mp3Files[i + 1]);
|
|
||||||
return subdir;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
useFirst = true;
|
|
||||||
}
|
|
||||||
// Reached the end of the MP3 files in the directory
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we get here, there were no MP3 files or subdirectories left to check
|
// If we get here, either we are at the last song, or the current song was not found
|
||||||
currentPlaying = nullptr;
|
currentPlaying = "";
|
||||||
Serial.println("no more nodes found");
|
Serial.println(F("no more nodes found"));
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
String DirectoryNode::getDirectoryStructureHTML() const
|
|
||||||
{
|
|
||||||
String html;
|
|
||||||
html.reserve(1024); // Reserve memory for better performance
|
|
||||||
if (name == "/")
|
|
||||||
{
|
|
||||||
html += "<ul>\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (name != "/")
|
|
||||||
{
|
|
||||||
html += "<li data-id=\"" + String(id) + "\"><b>" + name + "</b></li>\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
for (int i = 0; i < mp3Files.size(); i++)
|
|
||||||
{
|
|
||||||
html += "<li data-id=\"" + String(ids[i]) + "\">" + mp3Files[i] + "</li>\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
for (DirectoryNode *childNode : subdirectories)
|
|
||||||
{
|
|
||||||
html += childNode->getDirectoryStructureHTML();
|
|
||||||
}
|
|
||||||
if (name == "/")
|
|
||||||
{
|
|
||||||
html += "</ul>\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
|
|
||||||
void DirectoryNode::appendIndentation(String &html, int level) const
|
|
||||||
{
|
|
||||||
for (int i = 0; i < level; i++)
|
|
||||||
{
|
|
||||||
html += " ";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String DirectoryNode::getCurrentPlayingFilePath() const
|
|
||||||
{
|
|
||||||
if (currentPlaying != nullptr)
|
|
||||||
{
|
|
||||||
String filePath = "/" + name;
|
|
||||||
if (!filePath.endsWith("/"))
|
|
||||||
{
|
|
||||||
filePath += "/";
|
|
||||||
}
|
|
||||||
filePath += *currentPlaying;
|
|
||||||
return filePath;
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
#ifndef DIRECTORYNODE_H_
|
#ifndef DIRECTORYNODE_H_
|
||||||
#define DIRECTORYNODE_H_
|
#define DIRECTORYNODE_H_
|
||||||
|
class Print;
|
||||||
|
|
||||||
#include <SD.h>
|
#include <SD.h>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
|
||||||
const String sys_dir = "system";
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -13,15 +14,22 @@ class DirectoryNode {
|
|||||||
private:
|
private:
|
||||||
uint16_t id;
|
uint16_t id;
|
||||||
String name;
|
String name;
|
||||||
|
String dirPath;
|
||||||
std::vector<DirectoryNode*> subdirectories;
|
std::vector<DirectoryNode*> subdirectories;
|
||||||
std::vector<String> mp3Files;
|
std::vector<String> mp3Files;
|
||||||
std::vector<uint16_t> ids;
|
std::vector<uint16_t> ids;
|
||||||
const String* currentPlaying;
|
static const size_t path_size = 256;
|
||||||
|
String currentPlaying;
|
||||||
uint16_t currentPlayingId = 0;
|
uint16_t currentPlayingId = 0;
|
||||||
uint16_t secondsPlayed = 0;
|
uint16_t secondsPlayed = 0;
|
||||||
|
static const size_t buffer_size = path_size;
|
||||||
|
static char buffer[buffer_size];
|
||||||
|
|
||||||
|
String buildFullPath(const String& fileName) const;
|
||||||
|
void buildFullPath(const String &fileName, char* buffer, size_t bufferSize) const;
|
||||||
|
bool comparePathWithString(const char* path, const String& target) const;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public:
|
public:
|
||||||
DirectoryNode(const String& nodeName);
|
DirectoryNode(const String& nodeName);
|
||||||
@@ -33,13 +41,17 @@ public:
|
|||||||
const uint16_t getId() const;
|
const uint16_t getId() const;
|
||||||
const std::vector<DirectoryNode*>& getSubdirectories() const;
|
const std::vector<DirectoryNode*>& getSubdirectories() const;
|
||||||
const std::vector<String>& getMP3Files() const;
|
const std::vector<String>& getMP3Files() const;
|
||||||
|
const String& getDirPath() const;
|
||||||
|
uint16_t getFileIdAt(size_t i) const;
|
||||||
|
|
||||||
void setCurrentPlaying(const String* mp3File);
|
size_t getNumOfFiles() const;
|
||||||
const String* getCurrentPlaying() const;
|
|
||||||
|
void setCurrentPlaying(const String& mp3File);
|
||||||
|
const String& getCurrentPlaying() const;
|
||||||
const uint16_t getCurrentPlayingId() const;
|
const uint16_t getCurrentPlayingId() const;
|
||||||
|
|
||||||
void setSecondsPlayed(const uint32_t seconds);
|
void setSecondsPlayed(const uint32_t seconds);
|
||||||
uint32_t getSecondsPlayed();
|
uint32_t getSecondsPlayed() const;
|
||||||
|
|
||||||
uint16_t getNextId();
|
uint16_t getNextId();
|
||||||
|
|
||||||
@@ -47,15 +59,14 @@ public:
|
|||||||
void addMP3File(const String& mp3File);
|
void addMP3File(const String& mp3File);
|
||||||
void buildDirectoryTree(const char* currentPath);
|
void buildDirectoryTree(const char* currentPath);
|
||||||
void printDirectoryTree(int level = 0) const;
|
void printDirectoryTree(int level = 0) const;
|
||||||
DirectoryNode* advanceToMP3(const String* currentGlobal);
|
DirectoryNode* advanceToMP3(const String& songName);
|
||||||
DirectoryNode* advanceToNextMP3(const String* currentGlobal);
|
DirectoryNode* advanceToNextMP3(const String& currentGlobal);
|
||||||
DirectoryNode* goToPreviousMP3(uint32_t thresholdSeconds = 3);
|
DirectoryNode* goToPreviousMP3(uint32_t thresholdSeconds = 3);
|
||||||
|
DirectoryNode* findPreviousMP3Globally(const String& currentGlobal);
|
||||||
|
void buildFlatMP3List(std::vector<std::pair<DirectoryNode*, int>>& allMP3s);
|
||||||
DirectoryNode* advanceToMP3(const uint16_t id);
|
DirectoryNode* advanceToMP3(const uint16_t id);
|
||||||
void advanceToFirstMP3InThisNode();
|
void advanceToFirstMP3InThisNode();
|
||||||
String getDirectoryStructureHTML() const;
|
|
||||||
void appendIndentation(String& html, int level) const;
|
|
||||||
DirectoryNode* findFirstDirectoryWithMP3s();
|
DirectoryNode* findFirstDirectoryWithMP3s();
|
||||||
String getCurrentPlayingFilePath() const;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
115
src/DirectoryWalker.h
Normal file
115
src/DirectoryWalker.h
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
#ifndef DIRECTORY_WALKER_H
|
||||||
|
#define DIRECTORY_WALKER_H
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <vector>
|
||||||
|
#include "DirectoryNode.h"
|
||||||
|
|
||||||
|
struct WalkerState {
|
||||||
|
const DirectoryNode* node;
|
||||||
|
uint8_t phase; // 0: Start, 1: Files, 2: Subdirs, 3: End
|
||||||
|
size_t idx; // Index for vectors
|
||||||
|
|
||||||
|
WalkerState(const DirectoryNode* n) : node(n), phase(0), idx(0) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
class DirectoryWalker {
|
||||||
|
private:
|
||||||
|
std::vector<WalkerState> stack;
|
||||||
|
String pending;
|
||||||
|
size_t pendingOffset;
|
||||||
|
|
||||||
|
void generateNext() {
|
||||||
|
if (stack.empty()) return;
|
||||||
|
|
||||||
|
WalkerState& state = stack.back();
|
||||||
|
const DirectoryNode* node = state.node;
|
||||||
|
|
||||||
|
switch (state.phase) {
|
||||||
|
case 0: // Start
|
||||||
|
if (node->getName() == "/") {
|
||||||
|
pending += F("<ul>\r\n");
|
||||||
|
} else {
|
||||||
|
pending += F("<li data-id=\"");
|
||||||
|
pending += String(node->getId());
|
||||||
|
pending += F("\"><b>");
|
||||||
|
pending += node->getName();
|
||||||
|
pending += F("</b></li>\r\n");
|
||||||
|
}
|
||||||
|
state.phase = 1;
|
||||||
|
state.idx = 0;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 1: // Files
|
||||||
|
if (state.idx < node->getMP3Files().size()) {
|
||||||
|
pending += F("<li data-id=\"");
|
||||||
|
pending += String(node->getFileIdAt(state.idx));
|
||||||
|
pending += F("\">");
|
||||||
|
pending += node->getMP3Files()[state.idx];
|
||||||
|
pending += F("</li>\r\n");
|
||||||
|
state.idx++;
|
||||||
|
} else {
|
||||||
|
state.phase = 2;
|
||||||
|
state.idx = 0;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 2: // Subdirs
|
||||||
|
if (state.idx < node->getSubdirectories().size()) {
|
||||||
|
// Push child
|
||||||
|
const DirectoryNode* child = node->getSubdirectories()[state.idx];
|
||||||
|
state.idx++; // Advance index for when we return
|
||||||
|
stack.emplace_back(child);
|
||||||
|
// Next loop will process the child (Phase 0)
|
||||||
|
} else {
|
||||||
|
state.phase = 3;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 3: // End
|
||||||
|
if (node->getName() == "/") {
|
||||||
|
pending += F("</ul>\r\n");
|
||||||
|
}
|
||||||
|
stack.pop_back();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public:
|
||||||
|
DirectoryWalker(const DirectoryNode* root) : pendingOffset(0) {
|
||||||
|
if (root) {
|
||||||
|
stack.emplace_back(root);
|
||||||
|
// Reserve some space for pending string to avoid frequent reallocations
|
||||||
|
pending.reserve(256);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t read(uint8_t* buffer, size_t maxLen) {
|
||||||
|
size_t written = 0;
|
||||||
|
|
||||||
|
while (written < maxLen) {
|
||||||
|
// If pending buffer is empty or fully consumed, generate more
|
||||||
|
if (pending.length() == 0 || pendingOffset >= pending.length()) {
|
||||||
|
pending = ""; // Reset string content (capacity is kept)
|
||||||
|
pendingOffset = 0;
|
||||||
|
|
||||||
|
if (stack.empty()) {
|
||||||
|
break; // Done
|
||||||
|
}
|
||||||
|
generateNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy from pending to output buffer
|
||||||
|
if (pending.length() > pendingOffset) {
|
||||||
|
size_t available = pending.length() - pendingOffset;
|
||||||
|
size_t toCopy = std::min(available, maxLen - written);
|
||||||
|
memcpy(buffer + written, pending.c_str() + pendingOffset, toCopy);
|
||||||
|
written += toCopy;
|
||||||
|
pendingOffset += toCopy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return written;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif
|
||||||
337
src/WebContent.h
337
src/WebContent.h
@@ -1,337 +0,0 @@
|
|||||||
// HTML web page
|
|
||||||
const char index_html[] PROGMEM = R"rawliteral(
|
|
||||||
<!DOCTYPE HTML><html>
|
|
||||||
<head>
|
|
||||||
<title>HannaBox</title>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<link rel='stylesheet' href='/style.css' type='text/css' media='all' />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>🎵 HannaBox 🎵</h1>
|
|
||||||
<span id="state"></span><br/><br/>
|
|
||||||
<span id="voltage"></span><br/>
|
|
||||||
<span id="uid"></span><br/>
|
|
||||||
<span id="heap"></span><br/>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<button class="prev-button" onclick="simpleGetCall('previous');""></button>
|
|
||||||
<button class="play-button" onclick="simpleGetCall('toggleplaypause');"></button>
|
|
||||||
<button class="next-button" onclick="simpleGetCall('next');"></button><br/><br/>
|
|
||||||
</div>
|
|
||||||
<div class="slidecontainer">
|
|
||||||
|
|
||||||
<input name="progress" type="range" min="0" max="100" value="0" class="slider" id="progressSlider"
|
|
||||||
onchange="postValue('progress',document.getElementById('progressSlider').value);lastChange = Date.now();userIsInteracting = false;"
|
|
||||||
oninput="userIsInteracting = true;"
|
|
||||||
>
|
|
||||||
<label for="progress" id="progressLabel"></label>
|
|
||||||
</div>
|
|
||||||
<div class="slidecontainer">
|
|
||||||
<input name="volume" type="range" min="0" max="15" value="7" class="slider" id="volumeSlider"
|
|
||||||
onchange="postValue('volume',document.getElementById('volumeSlider').value);lastChange = Date.now()"
|
|
||||||
>
|
|
||||||
<label for="volume">Vol</label>
|
|
||||||
</div>
|
|
||||||
<!--
|
|
||||||
<button onmouseup="simpleGetCall('stop');" ontouchend="simpleGetCall('stop');">Stop</button>
|
|
||||||
-->
|
|
||||||
|
|
||||||
<p class="playlist-container">
|
|
||||||
<h2>🎶 Playlist 🎶</h2>
|
|
||||||
%DIRECTORY%
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form id="uploadForm" method="POST" action="/upload" enctype="multipart/form-data">
|
|
||||||
<input type="file" name="data" id="uploadFile" accept=".mp3,.wav,.flac,.m4a,.ogg"/>
|
|
||||||
<input type="submit" name="upload" value="Upload" title="Upload Audio File" id="uploadButton"/>
|
|
||||||
<div id="uploadStatus"></div>
|
|
||||||
<div id="uploadProgress" style="display: none;">
|
|
||||||
<div class="progress-bar">
|
|
||||||
<div class="progress-fill" id="progressFill"></div>
|
|
||||||
</div>
|
|
||||||
<span id="progressText">0%</span>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<h2>Edit RFID Mapping</h2>
|
|
||||||
<form id="editMappingForm">
|
|
||||||
<label for="rfid">RFID:</label>
|
|
||||||
<input type="text" id="rfid" name="rfid" required><br>
|
|
||||||
<label for="song">Song:</label>
|
|
||||||
<input type="text" id="song" name="song" required><br>
|
|
||||||
<button type="button" onclick="editMapping()">Update Mapping</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
setInterval(getState, 4000);
|
|
||||||
setInterval(updateProgress, 500); // Update progress every second
|
|
||||||
|
|
||||||
// Get the <li> elements
|
|
||||||
var liElements = document.querySelectorAll('ul li');
|
|
||||||
|
|
||||||
var lastChange = 0;
|
|
||||||
|
|
||||||
var lastStateUpdateTime = Date.now();
|
|
||||||
var songStartTime = 0;
|
|
||||||
var currentSongLength = 0;
|
|
||||||
var isPlaying = false;
|
|
||||||
var userIsInteracting = false; // Flag to track user interaction with the slider
|
|
||||||
|
|
||||||
|
|
||||||
// Add click event listener to each <li> element
|
|
||||||
liElements.forEach(function(li) {
|
|
||||||
li.addEventListener('click', function() {
|
|
||||||
//var liText = this.innerText;
|
|
||||||
var id = this.dataset.id;
|
|
||||||
playSongById(id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function simpleGetCall(endpoint) {
|
|
||||||
var xhr = new XMLHttpRequest();
|
|
||||||
xhr.open("GET", "/" + endpoint, true);
|
|
||||||
|
|
||||||
xhr.onreadystatechange = function() {
|
|
||||||
if (xhr.readyState === 4) {
|
|
||||||
getState(); // Fetch the latest state right after the button action
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
xhr.send();
|
|
||||||
}
|
|
||||||
|
|
||||||
function postValue(endpoint,value) {
|
|
||||||
var xhr = new XMLHttpRequest();
|
|
||||||
xhr.open("POST", "/" + endpoint, true);
|
|
||||||
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8");
|
|
||||||
xhr.send("value="+encodeURIComponent(value));
|
|
||||||
|
|
||||||
xhr.onreadystatechange = function() {
|
|
||||||
if (xhr.readyState === 4) {
|
|
||||||
getState(); // Fetch the latest state right after the button action
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getState() {
|
|
||||||
var xhr = new XMLHttpRequest();
|
|
||||||
xhr.onreadystatechange = function() {
|
|
||||||
if (xhr.readyState === 4) {
|
|
||||||
var state = JSON.parse(xhr.response);
|
|
||||||
isPlaying = state['playing'];
|
|
||||||
if (isPlaying) {
|
|
||||||
songStartTime = Date.now() - state['time'] * 1000;
|
|
||||||
currentSongLength = state['length'] * 1000;
|
|
||||||
}
|
|
||||||
lastStateUpdateTime = Date.now();
|
|
||||||
displayState(state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
xhr.open("GET","/state", true);
|
|
||||||
xhr.send();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function updateProgress() {
|
|
||||||
if (isPlaying && !userIsInteracting) { // Check if user is not interacting
|
|
||||||
var elapsedTime = Date.now() - songStartTime;
|
|
||||||
if (elapsedTime >= currentSongLength) {
|
|
||||||
elapsedTime = currentSongLength;
|
|
||||||
isPlaying = false; // Stop updating if the song has ended
|
|
||||||
}
|
|
||||||
var progressElement = document.getElementById('progressSlider');
|
|
||||||
progressElement.value = elapsedTime / 1000; // Convert to seconds
|
|
||||||
document.getElementById("progressLabel").innerHTML = Math.floor(elapsedTime / 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayState(state) {
|
|
||||||
document.getElementById("state").innerHTML = state['title'];
|
|
||||||
document.getElementById("progressLabel").innerHTML = state['time'];
|
|
||||||
document.getElementById("voltage").innerHTML = state['voltage']+' mV';
|
|
||||||
document.getElementById("heap").innerHTML = state['heap']+' bytes free heap';
|
|
||||||
document.getElementById("uid").innerHTML = 'Last NFC ID: '+state['uid'];
|
|
||||||
var elements = document.getElementsByClassName('play-button');
|
|
||||||
var btn = elements[0];
|
|
||||||
|
|
||||||
if (state['playing']) {
|
|
||||||
btn.classList.add('paused');
|
|
||||||
} else {
|
|
||||||
btn.classList.remove('paused');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Date.now()-lastChange>1200) {
|
|
||||||
var progress = document.getElementById('progressSlider');
|
|
||||||
progress.value = state['time'];
|
|
||||||
progress.max = state['length'];
|
|
||||||
|
|
||||||
var volume = document.getElementById('volumeSlider');
|
|
||||||
volume.value = state['volume'];
|
|
||||||
}
|
|
||||||
updateProgress();
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
function playSongById(id) {
|
|
||||||
var url = "/playbyid";
|
|
||||||
var params = "id="+id;
|
|
||||||
var http = new XMLHttpRequest();
|
|
||||||
|
|
||||||
http.open("GET", url+"?"+params, true);
|
|
||||||
http.onreadystatechange = function()
|
|
||||||
{
|
|
||||||
if(http.readyState == 4 && http.status == 200) {
|
|
||||||
getState();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
http.send(null);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
function playNamedSong(song) {
|
|
||||||
var xhr = new XMLHttpRequest();
|
|
||||||
xhr.open("POST", "/playnamed");
|
|
||||||
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8");
|
|
||||||
//application/x-www-form-urlencoded
|
|
||||||
var body = song;
|
|
||||||
xhr.send("title="+encodeURIComponent(body));
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
function editMapping() {
|
|
||||||
var rfid = document.getElementById('rfid').value;
|
|
||||||
var song = document.getElementById('song').value;
|
|
||||||
var xhr = new XMLHttpRequest();
|
|
||||||
xhr.open("POST", "/edit_mapping", true);
|
|
||||||
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8");
|
|
||||||
xhr.send("rfid=" + encodeURIComponent(rfid) + "&song=" + encodeURIComponent(song));
|
|
||||||
xhr.onreadystatechange = function() {
|
|
||||||
if (xhr.readyState === 4 && xhr.status === 200) {
|
|
||||||
alert("Mapping updated successfully!");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate file before upload
|
|
||||||
function validateFile(file) {
|
|
||||||
var maxSize = 50 * 1024 * 1024; // 50MB limit
|
|
||||||
var allowedTypes = ['audio/mpeg', 'audio/wav', 'audio/flac', 'audio/mp4', 'audio/ogg'];
|
|
||||||
var allowedExtensions = ['.mp3', '.wav', '.flac', '.m4a', '.ogg'];
|
|
||||||
|
|
||||||
if (file.size > maxSize) {
|
|
||||||
return 'File too large. Maximum size is 50MB.';
|
|
||||||
}
|
|
||||||
|
|
||||||
var fileName = file.name.toLowerCase();
|
|
||||||
var hasValidExtension = allowedExtensions.some(ext => fileName.endsWith(ext));
|
|
||||||
|
|
||||||
if (!hasValidExtension) {
|
|
||||||
return 'Invalid file type. Only audio files (.mp3, .wav, .flac, .m4a, .ogg) are allowed.';
|
|
||||||
}
|
|
||||||
|
|
||||||
return null; // No error
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle form submission with AJAX to show upload status
|
|
||||||
document.getElementById('uploadForm').addEventListener('submit', function(event) {
|
|
||||||
event.preventDefault(); // Prevent the default form submit
|
|
||||||
|
|
||||||
var fileInput = document.getElementById('uploadFile');
|
|
||||||
var file = fileInput.files[0];
|
|
||||||
|
|
||||||
if (!file) {
|
|
||||||
alert('Please select a file to upload.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var validationError = validateFile(file);
|
|
||||||
if (validationError) {
|
|
||||||
alert(validationError);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var form = event.target;
|
|
||||||
var formData = new FormData(form);
|
|
||||||
var uploadButton = document.getElementById('uploadButton');
|
|
||||||
var uploadStatus = document.getElementById('uploadStatus');
|
|
||||||
var uploadProgress = document.getElementById('uploadProgress');
|
|
||||||
var progressFill = document.getElementById('progressFill');
|
|
||||||
var progressText = document.getElementById('progressText');
|
|
||||||
|
|
||||||
// Disable upload button and show progress
|
|
||||||
uploadButton.disabled = true;
|
|
||||||
uploadButton.value = 'Uploading...';
|
|
||||||
uploadProgress.style.display = 'block';
|
|
||||||
uploadStatus.innerHTML = 'Preparing upload...';
|
|
||||||
|
|
||||||
var xhr = new XMLHttpRequest();
|
|
||||||
xhr.open('POST', '/upload', true);
|
|
||||||
|
|
||||||
xhr.upload.onloadstart = function() {
|
|
||||||
uploadStatus.innerHTML = 'Upload started...';
|
|
||||||
};
|
|
||||||
|
|
||||||
xhr.upload.onprogress = function(event) {
|
|
||||||
if (event.lengthComputable) {
|
|
||||||
var percentComplete = Math.round((event.loaded / event.total) * 100);
|
|
||||||
progressFill.style.width = percentComplete + '%';
|
|
||||||
progressText.innerHTML = percentComplete + '%';
|
|
||||||
uploadStatus.innerHTML = 'Uploading: ' + percentComplete + '% (' +
|
|
||||||
Math.round(event.loaded / 1024) + 'KB / ' +
|
|
||||||
Math.round(event.total / 1024) + 'KB)';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
xhr.upload.onerror = function() {
|
|
||||||
uploadStatus.innerHTML = 'Upload failed due to network error.';
|
|
||||||
resetUploadForm();
|
|
||||||
};
|
|
||||||
|
|
||||||
xhr.upload.onabort = function() {
|
|
||||||
uploadStatus.innerHTML = 'Upload was cancelled.';
|
|
||||||
resetUploadForm();
|
|
||||||
};
|
|
||||||
|
|
||||||
xhr.onreadystatechange = function() {
|
|
||||||
if (xhr.readyState === 4) { // Request is done
|
|
||||||
if (xhr.status >= 200 && xhr.status < 300) { // Success status code range
|
|
||||||
uploadStatus.innerHTML = 'Upload completed successfully!';
|
|
||||||
progressFill.style.width = '100%';
|
|
||||||
progressText.innerHTML = '100%';
|
|
||||||
|
|
||||||
setTimeout(function() {
|
|
||||||
alert('File uploaded successfully!');
|
|
||||||
location.reload(); // Reload to get updated playlist
|
|
||||||
}, 1000);
|
|
||||||
} else {
|
|
||||||
var errorMsg = xhr.responseText || 'Unknown error occurred';
|
|
||||||
uploadStatus.innerHTML = 'Upload failed: ' + errorMsg;
|
|
||||||
alert('Upload failed: ' + errorMsg);
|
|
||||||
resetUploadForm();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
xhr.send(formData); // Send the form data using XMLHttpRequest
|
|
||||||
});
|
|
||||||
|
|
||||||
function resetUploadForm() {
|
|
||||||
var uploadButton = document.getElementById('uploadButton');
|
|
||||||
var uploadProgress = document.getElementById('uploadProgress');
|
|
||||||
var progressFill = document.getElementById('progressFill');
|
|
||||||
var progressText = document.getElementById('progressText');
|
|
||||||
|
|
||||||
uploadButton.disabled = false;
|
|
||||||
uploadButton.value = 'Upload';
|
|
||||||
uploadProgress.style.display = 'none';
|
|
||||||
progressFill.style.width = '0%';
|
|
||||||
progressText.innerHTML = '0%';
|
|
||||||
|
|
||||||
// Clear file input
|
|
||||||
document.getElementById('uploadFile').value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>)rawliteral";
|
|
||||||
203
src/config.cpp
Normal file
203
src/config.cpp
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
#include "config.h"
|
||||||
|
#include "globals.h"
|
||||||
|
#include <SD.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <ctype.h>
|
||||||
|
#include <strings.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
|
||||||
|
// Global config instance
|
||||||
|
Config config;
|
||||||
|
|
||||||
|
const char* getConfigFilePath() {
|
||||||
|
static char path[64];
|
||||||
|
static bool init = false;
|
||||||
|
if (!init) {
|
||||||
|
// Build "/<sys_dir>/<config_file>" once into a static buffer
|
||||||
|
snprintf(path, sizeof(path), "/%s/%s", sys_dir ? sys_dir : "", config_file ? config_file : "");
|
||||||
|
init = true;
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setDefaultConfig() {
|
||||||
|
config.initialVolume = 7;
|
||||||
|
config.maxVolume = 15;
|
||||||
|
config.sleepTime = 1800000;
|
||||||
|
config.minVoltage = 3000;
|
||||||
|
config.voltage100Percent = 4200;
|
||||||
|
config.sleepDelay = 1800000;
|
||||||
|
config.sleepMessageDelay = 1798000;
|
||||||
|
config.rfidLoopInterval = 25;
|
||||||
|
config.startAtStoredProgress = true;
|
||||||
|
config.wifiSSID[0] = '\0';
|
||||||
|
config.wifiPassword[0] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
bool loadConfig() {
|
||||||
|
const char* configPath = getConfigFilePath();
|
||||||
|
|
||||||
|
if (!SD.exists(configPath)) {
|
||||||
|
Serial.println(F("Config file not found, using defaults"));
|
||||||
|
setDefaultConfig();
|
||||||
|
return saveConfig(); // Create config file with defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
File file = SD.open(configPath, FILE_READ);
|
||||||
|
if (!file) {
|
||||||
|
Serial.println(F("Failed to open config file"));
|
||||||
|
setDefaultConfig();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set defaults first
|
||||||
|
setDefaultConfig();
|
||||||
|
|
||||||
|
// Parse config file line by line using a fixed-size buffer (no dynamic allocation)
|
||||||
|
char line[160];
|
||||||
|
while (true) {
|
||||||
|
int idx = 0;
|
||||||
|
int c;
|
||||||
|
// Read characters until newline or EOF
|
||||||
|
while (idx < (int)sizeof(line) - 1 && (c = file.read()) >= 0) {
|
||||||
|
if (c == '\r') continue;
|
||||||
|
if (c == '\n') break;
|
||||||
|
line[idx++] = (char)c;
|
||||||
|
}
|
||||||
|
if (idx == 0 && c < 0) {
|
||||||
|
// EOF with no more data
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
line[idx] = '\0';
|
||||||
|
|
||||||
|
// Trim leading/trailing whitespace
|
||||||
|
char* s = line;
|
||||||
|
while (*s && isspace((unsigned char)*s)) ++s;
|
||||||
|
char* end = s + strlen(s);
|
||||||
|
while (end > s && isspace((unsigned char)end[-1])) --end;
|
||||||
|
*end = '\0';
|
||||||
|
|
||||||
|
// Skip empty lines and comments
|
||||||
|
if (*s == '\0' || *s == '#') {
|
||||||
|
if (c < 0) break;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split at '='
|
||||||
|
char* eq = strchr(s, '=');
|
||||||
|
if (!eq) {
|
||||||
|
if (c < 0) break;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
*eq = '\0';
|
||||||
|
char* key = s;
|
||||||
|
char* val = eq + 1;
|
||||||
|
|
||||||
|
// Trim key
|
||||||
|
while (*key && isspace((unsigned char)*key)) ++key;
|
||||||
|
char* kend = key + strlen(key);
|
||||||
|
while (kend > key && isspace((unsigned char)kend[-1])) --kend;
|
||||||
|
*kend = '\0';
|
||||||
|
|
||||||
|
// Trim val
|
||||||
|
while (*val && isspace((unsigned char)*val)) ++val;
|
||||||
|
char* vend = val + strlen(val);
|
||||||
|
while (vend > val && isspace((unsigned char)vend[-1])) --vend;
|
||||||
|
*vend = '\0';
|
||||||
|
|
||||||
|
// Parse each configuration value
|
||||||
|
if (strcmp(key, "initialVolume") == 0) {
|
||||||
|
long v = strtol(val, nullptr, 10);
|
||||||
|
if (v < 0) v = 0; if (v > 21) v = 21;
|
||||||
|
config.initialVolume = (uint8_t)v;
|
||||||
|
} else if (strcmp(key, "maxVolume") == 0) {
|
||||||
|
long v = strtol(val, nullptr, 10);
|
||||||
|
if (v < 1) v = 1; if (v > 21) v = 21;
|
||||||
|
config.maxVolume = (uint8_t)v;
|
||||||
|
} else if (strcmp(key, "sleepTime") == 0) {
|
||||||
|
config.sleepTime = (uint32_t)strtoul(val, nullptr, 10);
|
||||||
|
} else if (strcmp(key, "minVoltage") == 0) {
|
||||||
|
config.minVoltage = (uint16_t)strtoul(val, nullptr, 10);
|
||||||
|
} else if (strcmp(key, "voltage100Percent") == 0) {
|
||||||
|
config.voltage100Percent = (uint16_t)strtoul(val, nullptr, 10);
|
||||||
|
} else if (strcmp(key, "sleepDelay") == 0) {
|
||||||
|
config.sleepDelay = (uint32_t)strtoul(val, nullptr, 10);
|
||||||
|
} else if (strcmp(key, "sleepMessageDelay") == 0) {
|
||||||
|
config.sleepMessageDelay = (uint32_t)strtoul(val, nullptr, 10);
|
||||||
|
} else if (strcmp(key, "rfidLoopInterval") == 0) {
|
||||||
|
long v = strtol(val, nullptr, 10);
|
||||||
|
if (v < 1) v = 1; if (v > 255) v = 255;
|
||||||
|
config.rfidLoopInterval = (uint8_t)v;
|
||||||
|
} else if (strcmp(key, "startAtStoredProgress") == 0) {
|
||||||
|
bool b = (strcmp(val, "1") == 0) || (strcasecmp(val, "true") == 0);
|
||||||
|
config.startAtStoredProgress = b;
|
||||||
|
} else if (strcmp(key, "wifiSSID") == 0) {
|
||||||
|
strncpy(config.wifiSSID, val, sizeof(config.wifiSSID) - 1);
|
||||||
|
config.wifiSSID[sizeof(config.wifiSSID) - 1] = '\0';
|
||||||
|
} else if (strcmp(key, "wifiPassword") == 0) {
|
||||||
|
strncpy(config.wifiPassword, val, sizeof(config.wifiPassword) - 1);
|
||||||
|
config.wifiPassword[sizeof(config.wifiPassword) - 1] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c < 0) break; // EOF after processing last line
|
||||||
|
}
|
||||||
|
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
Serial.println(F("Config loaded successfully"));
|
||||||
|
Serial.print(F("Initial Volume: ")); Serial.println(config.initialVolume);
|
||||||
|
Serial.print(F("Max Volume: ")); Serial.println(config.maxVolume);
|
||||||
|
Serial.print(F("Sleep Delay: ")); Serial.println(config.sleepDelay);
|
||||||
|
Serial.print(F("RFID Interval: ")); Serial.println(config.rfidLoopInterval);
|
||||||
|
Serial.print(F("min Voltage: ")); Serial.println(config.minVoltage);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool saveConfig() {
|
||||||
|
const char* configPath = getConfigFilePath();
|
||||||
|
|
||||||
|
File file = SD.open(configPath, FILE_WRITE);
|
||||||
|
if (!file) {
|
||||||
|
Serial.println(F("Failed to create config file"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write config file with comments for user reference
|
||||||
|
file.println(F("# HannaBox Conf File"));
|
||||||
|
file.println(F("# format: key=value"));
|
||||||
|
file.println();
|
||||||
|
|
||||||
|
file.println(F("# Audio"));
|
||||||
|
file.print(F("initialVolume=")); file.println(config.initialVolume);
|
||||||
|
file.print(F("maxVolume=")); file.println(config.maxVolume);
|
||||||
|
file.println();
|
||||||
|
|
||||||
|
file.println(F("# Power Management (in milliseconds)"));
|
||||||
|
file.print(F("sleepTime=")); file.println(config.sleepTime);
|
||||||
|
file.print(F("sleepDelay=")); file.println(config.sleepDelay);
|
||||||
|
file.print(F("sleepMessageDelay=")); file.println(config.sleepMessageDelay);
|
||||||
|
file.println();
|
||||||
|
|
||||||
|
file.println(F("# Battery (in millivolts)"));
|
||||||
|
file.print(F("minVoltage=")); file.println(config.minVoltage);
|
||||||
|
file.print(F("voltage100Percent=")); file.println(config.voltage100Percent);
|
||||||
|
file.println();
|
||||||
|
|
||||||
|
file.println(F("# RFID"));
|
||||||
|
file.print(F("rfidLoopInterval=")); file.println(config.rfidLoopInterval);
|
||||||
|
file.println();
|
||||||
|
|
||||||
|
file.println(F("# Playback"));
|
||||||
|
file.print(F("startAtStoredProgress=")); file.println(config.startAtStoredProgress ? F("true") : F("false"));
|
||||||
|
file.println();
|
||||||
|
|
||||||
|
file.println(F("# WiFi (leave empty to use current WiFiManager)"));
|
||||||
|
file.print(F("wifiSSID=")); file.println(config.wifiSSID);
|
||||||
|
file.print(F("wifiPassword=")); file.println(config.wifiPassword);
|
||||||
|
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
Serial.println(F("Config saved successfully"));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
44
src/config.h
Normal file
44
src/config.h
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
#ifndef CONFIG_H_
|
||||||
|
#define CONFIG_H_
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
|
||||||
|
// Keep the structure compact to minimize padding and RAM usage.
|
||||||
|
// Order fields from wider to narrower types, and avoid inline default initializers.
|
||||||
|
// Defaults are applied in setDefaultConfig() to keep initialization simple and lightweight.
|
||||||
|
struct Config {
|
||||||
|
// 32-bit values
|
||||||
|
uint32_t sleepTime; // ms
|
||||||
|
uint32_t sleepDelay; // ms
|
||||||
|
uint32_t sleepMessageDelay; // ms
|
||||||
|
|
||||||
|
// 16-bit values
|
||||||
|
uint16_t minVoltage; // mV
|
||||||
|
uint16_t voltage100Percent; // mV
|
||||||
|
|
||||||
|
// 8-bit values
|
||||||
|
uint8_t initialVolume;
|
||||||
|
uint8_t maxVolume;
|
||||||
|
uint8_t rfidLoopInterval;
|
||||||
|
|
||||||
|
// flags
|
||||||
|
bool startAtStoredProgress;
|
||||||
|
|
||||||
|
// WiFi credentials (fixed-size buffers, no heap allocations)
|
||||||
|
char wifiSSID[32]; // empty => use current mechanism
|
||||||
|
char wifiPassword[64]; // empty => use current mechanism
|
||||||
|
};
|
||||||
|
|
||||||
|
// Global config instance
|
||||||
|
extern Config config;
|
||||||
|
|
||||||
|
// Function declarations
|
||||||
|
bool loadConfig();
|
||||||
|
bool saveConfig();
|
||||||
|
void setDefaultConfig();
|
||||||
|
|
||||||
|
// Returns a pointer to a static buffer with the absolute config file path.
|
||||||
|
// Avoids dynamic allocation/Arduino String to reduce heap fragmentation.
|
||||||
|
const char* getConfigFilePath();
|
||||||
|
|
||||||
|
#endif
|
||||||
232
src/css.h
232
src/css.h
@@ -1,232 +0,0 @@
|
|||||||
const char css[] PROGMEM = R"rawliteral(
|
|
||||||
body {
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
|
||||||
text-align: center; /* Center align elements */
|
|
||||||
background-color: #f4f4f4; /* Light background */
|
|
||||||
}
|
|
||||||
|
|
||||||
.playlist-container {
|
|
||||||
max-height: 300px;
|
|
||||||
overflow-y: auto;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
padding: 10px;
|
|
||||||
margin-top: 20px;
|
|
||||||
text-align: left; /* Align playlist text to the left */
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
cursor: pointer;
|
|
||||||
list-style-type: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-button, .next-button, .prev-button {
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.3s ease; /* Smooth transition for hover */
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-button {
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 5% auto;
|
|
||||||
height: 50px; /* Consistent size for play button */
|
|
||||||
border-color: transparent transparent transparent #007bff;
|
|
||||||
border-style: solid;
|
|
||||||
border-width: 25px 0 25px 40px; /* Adjusted size */
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-button.paused {
|
|
||||||
border-style: double;
|
|
||||||
border-width: 25px 0 25px 40px; /* Same size for pause button */
|
|
||||||
height: 50px; /* Consistent height */
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-button:hover {
|
|
||||||
border-color: transparent transparent transparent #0056b3; /* Darker blue on hover */
|
|
||||||
}
|
|
||||||
|
|
||||||
.next-button, .prev-button {
|
|
||||||
padding: 0;
|
|
||||||
margin: 10px;
|
|
||||||
border-color: transparent #007bff transparent #007bff;
|
|
||||||
border-style: solid;
|
|
||||||
}
|
|
||||||
|
|
||||||
.next-button {
|
|
||||||
border-width: 15px 0 15px 25px;
|
|
||||||
box-shadow: 8px 0 0 0 #007bff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.next-button:hover {
|
|
||||||
border-color: transparent #0056b3 transparent #0056b3;
|
|
||||||
box-shadow: 8px 0 0 0 #0056b3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prev-button {
|
|
||||||
border-width: 15px 25px 15px 0;
|
|
||||||
box-shadow: -8px 0 0 0 #007bff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prev-button:hover {
|
|
||||||
border-color: transparent #0056b3 transparent #0056b3;
|
|
||||||
box-shadow: -8px 0 0 0 #0056b3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider {
|
|
||||||
width: 90%; /* Make slider wider for easier interaction */
|
|
||||||
margin: 10px auto; /* Center align the slider */
|
|
||||||
}
|
|
||||||
|
|
||||||
.slidecontainer {
|
|
||||||
margin: 20px 0; /* Space out elements */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Upload progress bar styles */
|
|
||||||
.progress-bar {
|
|
||||||
width: 100%;
|
|
||||||
height: 20px;
|
|
||||||
background-color: #e0e0e0;
|
|
||||||
border-radius: 10px;
|
|
||||||
overflow: hidden;
|
|
||||||
margin: 10px 0;
|
|
||||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-fill {
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, #007bff, #0056b3);
|
|
||||||
width: 0%;
|
|
||||||
transition: width 0.3s ease;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#uploadProgress {
|
|
||||||
margin: 15px 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#progressText {
|
|
||||||
font-weight: bold;
|
|
||||||
color: #007bff;
|
|
||||||
margin-left: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#uploadStatus {
|
|
||||||
margin: 10px 0;
|
|
||||||
padding: 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
#uploadStatus:not(:empty) {
|
|
||||||
background-color: #e7f3ff;
|
|
||||||
border: 1px solid #007bff;
|
|
||||||
color: #0056b3;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form styling improvements */
|
|
||||||
#uploadForm {
|
|
||||||
background-color: white;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#uploadButton {
|
|
||||||
background-color: #007bff;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 10px 20px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 16px;
|
|
||||||
margin-left: 10px;
|
|
||||||
transition: background-color 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
#uploadButton:hover:not(:disabled) {
|
|
||||||
background-color: #0056b3;
|
|
||||||
}
|
|
||||||
|
|
||||||
#uploadButton:disabled {
|
|
||||||
background-color: #6c757d;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
#uploadFile {
|
|
||||||
padding: 8px;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* RFID mapping form styling */
|
|
||||||
#editMappingForm {
|
|
||||||
background-color: white;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
margin: 20px 0;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
#editMappingForm label {
|
|
||||||
display: block;
|
|
||||||
margin: 10px 0 5px 0;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
#editMappingForm input[type="text"] {
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
#editMappingForm button {
|
|
||||||
background-color: #28a745;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 10px 20px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 16px;
|
|
||||||
margin-top: 10px;
|
|
||||||
transition: background-color 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
#editMappingForm button:hover {
|
|
||||||
background-color: #218838;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive design improvements */
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
body {
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider {
|
|
||||||
width: 95%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#uploadForm, #editMappingForm {
|
|
||||||
padding: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#uploadButton, #editMappingForm button {
|
|
||||||
width: 100%;
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#uploadFile {
|
|
||||||
width: 100%;
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
)rawliteral";
|
|
||||||
@@ -1,93 +1,38 @@
|
|||||||
#ifndef GLOBALS_H_
|
#ifndef GLOBALS_H_
|
||||||
#define GLOBALS_H_
|
#define GLOBALS_H_
|
||||||
|
|
||||||
void stop();
|
static const char* sys_dir = "system";
|
||||||
|
|
||||||
void start();
|
static const char* sleep_sound = "sleep.mp3";
|
||||||
|
|
||||||
bool playFile(const char* filename, uint32_t resumeFilePos = 0);
|
static const char* startup_sound = "start.mp3";
|
||||||
|
|
||||||
void loop2(void* parameter);
|
static const char* index_file = "index.html";
|
||||||
|
|
||||||
void id_song_action(AsyncWebServerRequest *request);
|
static const char* style_file = "style.css";
|
||||||
|
|
||||||
void progress_action(AsyncWebServerRequest *request);
|
static const char* script_file = "script.js";
|
||||||
|
|
||||||
void volume_action(AsyncWebServerRequest *request);
|
static const char* mapping_file = "mapping.txt";
|
||||||
|
|
||||||
boolean buttonPressed(const uint8_t pin);
|
static const char* progress_file = "progress.txt";
|
||||||
|
|
||||||
/**
|
static const char* config_file = "config.txt";
|
||||||
* Helper routine to dump a byte array as hex values to Serial.
|
|
||||||
*/
|
|
||||||
void dump_byte_array(byte *buffer, byte bufferSize)
|
|
||||||
{
|
|
||||||
for (byte i = 0; i < bufferSize; i++)
|
|
||||||
{
|
|
||||||
Serial.print(buffer[i] < 0x10 ? " 0" : " ");
|
|
||||||
Serial.print(buffer[i], HEX);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String getRFIDString(byte uidByte[10])
|
static const char* txt_html_charset = "text/html; charset=UTF-8";
|
||||||
{
|
|
||||||
String uidString = String(uidByte[0]) + " " + String(uidByte[1]) + " " +
|
|
||||||
String(uidByte[2]) + " " + String(uidByte[3]);
|
|
||||||
return uidString;
|
|
||||||
}
|
|
||||||
|
|
||||||
void writeFile(fs::FS &fs, const char * path, const char * message){
|
static const char* txt_plain = "text/plain; charset=UTF-8";
|
||||||
Serial.printf("Writing file: %s\n", path);
|
|
||||||
|
|
||||||
File file = fs.open(path, FILE_WRITE);
|
static const char* hdr_cache_control_key = "Cache-Control";
|
||||||
if(!file){
|
|
||||||
Serial.println("Failed to open file for writing");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if(file.print(message)){
|
|
||||||
Serial.println("File written");
|
|
||||||
} else {
|
|
||||||
Serial.println("Write failed");
|
|
||||||
}
|
|
||||||
file.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
static const char* hdr_cache_control_val = "no-store";
|
||||||
|
|
||||||
unsigned long lastStart = 0;
|
static const char* hdr_connection_key = "Connection";
|
||||||
|
|
||||||
unsigned long lastInteraction = 0;
|
static const char* hdr_connection_val = "close";
|
||||||
|
|
||||||
boolean sleepSoundPlayed = false;
|
const size_t buffer_size = 80;
|
||||||
|
|
||||||
boolean startupSoundPlayed = false;
|
|
||||||
|
|
||||||
boolean continuousMode = false;
|
|
||||||
|
|
||||||
uint8_t buttontoignore = 0;
|
|
||||||
|
|
||||||
uint32_t lastVoltage = 0;
|
|
||||||
|
|
||||||
uint loopCounter = 0;
|
|
||||||
|
|
||||||
String lastUid = "";
|
|
||||||
|
|
||||||
uint16_t currentSongId = 0;
|
|
||||||
|
|
||||||
uint32_t currentSongSeconds = 0;
|
|
||||||
|
|
||||||
boolean continuePlaying = false;
|
|
||||||
|
|
||||||
boolean prepareSleepMode = false;
|
|
||||||
|
|
||||||
const String sleep_sound = "sleep.mp3";
|
|
||||||
|
|
||||||
const String startup_sound = "start.mp3";
|
|
||||||
|
|
||||||
const String mapping_file = "/mapping.txt";
|
|
||||||
|
|
||||||
const String progress_file = "progress.txt";
|
|
||||||
|
|
||||||
std::map<String, String> rfid_map;
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
const long sleepMessageDelay = 28000;
|
const long sleepMessageDelay = 28000;
|
||||||
|
|||||||
1870
src/main.cpp
1870
src/main.cpp
File diff suppressed because it is too large
Load Diff
222
src/main.h
Normal file
222
src/main.h
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
#ifndef MAIN_H_
|
||||||
|
#define MAIN_H_
|
||||||
|
|
||||||
|
// define pins for RFID
|
||||||
|
#define CS_RFID 32 // SIC, tried 4 and 32 but only this worked!
|
||||||
|
#define RST_RFID 33
|
||||||
|
// this does not work as the irq pin is input only:
|
||||||
|
#define IRQ_RFID 34
|
||||||
|
|
||||||
|
// Audio DAC
|
||||||
|
#define I2S_DOUT 26 // connect to DAC pin DIN
|
||||||
|
#define I2S_BCLK 27 // connect to DAC pin BCK
|
||||||
|
#define I2S_LRC 25 // connect to DAC pin LCK
|
||||||
|
|
||||||
|
#define BTN_START_STOP 4 // Button on XX and GND
|
||||||
|
#define BTN_NEXT 17
|
||||||
|
#define BTN_PREV 16
|
||||||
|
|
||||||
|
#define CS_SDCARD 22
|
||||||
|
|
||||||
|
#define BAT_VOLTAGE_PIN 35
|
||||||
|
|
||||||
|
#define RFID_LOOP_INTERVAL 25
|
||||||
|
|
||||||
|
#define VOLTAGE_LOOP_INTERVAL 5000
|
||||||
|
|
||||||
|
#define VOLTAGE_THRESHOLD 0
|
||||||
|
|
||||||
|
#define SHORT_PRESS_TIME 250
|
||||||
|
|
||||||
|
#define LONG_PRESS_TIME 1000
|
||||||
|
|
||||||
|
#define MAX_WEBREQUEST_BLOCKINGS 10000
|
||||||
|
|
||||||
|
#define MAX_VOL 15
|
||||||
|
|
||||||
|
File root;
|
||||||
|
File mp3File;
|
||||||
|
|
||||||
|
Audio audio;
|
||||||
|
|
||||||
|
uint volume = 7;
|
||||||
|
|
||||||
|
// Folder-play tracking: flattened list of files inside a mapped folder and current index
|
||||||
|
// Used when a mapping targets a folder (play folder once or loop folder)
|
||||||
|
static std::vector<std::pair<DirectoryNode *, int>> folderFlatList;
|
||||||
|
static int folderFlatIndex = -1;
|
||||||
|
static String folderRootPath = "";
|
||||||
|
// Pointer to the root DirectoryNode for active folder-mode playback
|
||||||
|
DirectoryNode *folderRootNode = nullptr;
|
||||||
|
|
||||||
|
AsyncWebServer server(80);
|
||||||
|
DNSServer dns;
|
||||||
|
|
||||||
|
// static variable has to be instantiated outside of class definition:
|
||||||
|
uint16_t DirectoryNode::idCounter = 0;
|
||||||
|
|
||||||
|
DirectoryNode rootNode("/");
|
||||||
|
DirectoryNode *currentNode = nullptr;
|
||||||
|
|
||||||
|
volatile bool newRfidInt = false;
|
||||||
|
volatile bool playButtonDown = false;
|
||||||
|
// Track if play button hold is active and if volume was adjusted during this hold
|
||||||
|
volatile bool playHoldActive = false;
|
||||||
|
volatile bool volumeAdjustedDuringHold = false;
|
||||||
|
volatile uint8_t sd_lock_flag = 0;
|
||||||
|
|
||||||
|
MFRC522 rfid(CS_RFID, RST_RFID); // instatiate a MFRC522 reader object.
|
||||||
|
|
||||||
|
TaskHandle_t RfidTask;
|
||||||
|
|
||||||
|
bool asyncStop = false;
|
||||||
|
|
||||||
|
bool asyncStart = false;
|
||||||
|
|
||||||
|
bool asyncTogglePlayPause = false;
|
||||||
|
|
||||||
|
bool asyncNext = false;
|
||||||
|
|
||||||
|
bool asyncPrev = false;
|
||||||
|
|
||||||
|
bool asyncReset = false;
|
||||||
|
|
||||||
|
bool SDActive = false;
|
||||||
|
|
||||||
|
bool RFIDActive = false;
|
||||||
|
|
||||||
|
|
||||||
|
// Web request concurrency counter and helpers (atomic via GCC builtins)
|
||||||
|
volatile uint32_t webreq_cnt = 0;
|
||||||
|
static inline void webreq_enter() { __sync_add_and_fetch(&webreq_cnt, 1); }
|
||||||
|
static inline void webreq_exit() { __sync_sub_and_fetch(&webreq_cnt, 1); }
|
||||||
|
volatile bool server_reset_pending = false;
|
||||||
|
|
||||||
|
uint16_t voltage_threshold_counter = 0;
|
||||||
|
|
||||||
|
size_t free_heap = 0;
|
||||||
|
|
||||||
|
void stop();
|
||||||
|
|
||||||
|
void start();
|
||||||
|
|
||||||
|
bool playFile(const char* filename, uint32_t resumeFilePos = 0);
|
||||||
|
|
||||||
|
void loop2(void* parameter);
|
||||||
|
|
||||||
|
void id_song_action(AsyncWebServerRequest *request);
|
||||||
|
|
||||||
|
void progress_action(AsyncWebServerRequest *request);
|
||||||
|
|
||||||
|
void volume_action(AsyncWebServerRequest *request);
|
||||||
|
|
||||||
|
void init_webserver();
|
||||||
|
|
||||||
|
boolean buttonPressed(const uint8_t pin);
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper routine to dump a byte array as hex values to Serial.
|
||||||
|
*/
|
||||||
|
void dump_byte_array(byte *buffer, byte bufferSize)
|
||||||
|
{
|
||||||
|
for (byte i = 0; i < bufferSize; i++)
|
||||||
|
{
|
||||||
|
Serial.print(buffer[i] < 0x10 ? " 0" : " ");
|
||||||
|
Serial.print(buffer[i], HEX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Simple spinlock using older GCC sync builtins (no libatomic required).
|
||||||
|
sd_lock_acquire() will block (with a small delay) until the lock is free.
|
||||||
|
sd_lock_release() releases the lock. This is sufficient for short SD ops. */
|
||||||
|
static inline void sd_lock_acquire()
|
||||||
|
{
|
||||||
|
while (__sync_lock_test_and_set(&sd_lock_flag, 1))
|
||||||
|
{
|
||||||
|
delay(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static inline void sd_lock_release()
|
||||||
|
{
|
||||||
|
__sync_lock_release(&sd_lock_flag);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
String getRFIDString(byte uidByte[10])
|
||||||
|
{
|
||||||
|
String uidString = String(uidByte[0]) + " " + String(uidByte[1]) + " " +
|
||||||
|
String(uidByte[2]) + " " + String(uidByte[3]);
|
||||||
|
return uidString;
|
||||||
|
}
|
||||||
|
|
||||||
|
void writeFile(fs::FS &fs, const char * path, const char * message){
|
||||||
|
Serial.printf("Writing file: %s\n", path);
|
||||||
|
|
||||||
|
File file = fs.open(path, FILE_WRITE);
|
||||||
|
if(!file){
|
||||||
|
Serial.println("Failed to open file for writing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(file.print(message)){
|
||||||
|
Serial.println("File written");
|
||||||
|
} else {
|
||||||
|
Serial.println("Write failed");
|
||||||
|
}
|
||||||
|
file.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
unsigned long lastStart = 0;
|
||||||
|
|
||||||
|
unsigned long lastInteraction = 0;
|
||||||
|
|
||||||
|
boolean sleepSoundPlayed = false;
|
||||||
|
|
||||||
|
boolean startupSoundPlayed = false;
|
||||||
|
|
||||||
|
boolean continuousMode = false;
|
||||||
|
|
||||||
|
uint8_t buttontoignore = 0;
|
||||||
|
|
||||||
|
uint32_t lastVoltage = 0;
|
||||||
|
|
||||||
|
uint loopCounter = 0;
|
||||||
|
|
||||||
|
String lastUid = "";
|
||||||
|
|
||||||
|
uint16_t currentSongId = 0;
|
||||||
|
|
||||||
|
uint32_t currentSongSeconds = 0;
|
||||||
|
|
||||||
|
boolean continuePlaying = false;
|
||||||
|
|
||||||
|
boolean prepareSleepMode = false;
|
||||||
|
|
||||||
|
class DirectoryNode;
|
||||||
|
|
||||||
|
/* Mapping entry that stores target (file or folder) and playback mode:
|
||||||
|
* 's' = single (default) - play only the selected song (or single file in folder)
|
||||||
|
* 'f' = folder - play files inside the selected folder, then stop
|
||||||
|
* 'r' = random-folder - play files inside the selected folder in random order, then stop
|
||||||
|
* 'c' = continuous - continuously play (like previous continuousMode)
|
||||||
|
*/
|
||||||
|
struct MappingEntry {
|
||||||
|
String target;
|
||||||
|
char mode;
|
||||||
|
MappingEntry() : target(""), mode('s') {}
|
||||||
|
MappingEntry(const String& t, char m) : target(t), mode(m) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
std::map<String, MappingEntry> rfid_map;
|
||||||
|
|
||||||
|
// Folder-play helper: when a mapping requests "folder only" playback we keep
|
||||||
|
// track of the folder root node so EOF handling can advance only inside that folder.
|
||||||
|
bool folderModeActive = true;
|
||||||
|
|
||||||
|
bool pendingSeek = false;
|
||||||
|
uint32_t pendingSeekSeconds = 0;
|
||||||
|
|
||||||
|
static const size_t MAX_DEPTH = 32;
|
||||||
|
|
||||||
|
#endif
|
||||||
BIN
web/hanna.png
Normal file
BIN
web/hanna.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
193
web/index.html
Normal file
193
web/index.html
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
<!DOCTYPE HTML>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>HannaBox</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="stylesheet" href="style.css" type="text/css" media="all" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">
|
||||||
|
<div>
|
||||||
|
<h1>HannaBox</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-status-group">
|
||||||
|
<div id="batteryStatus" class="battery-status" title="Battery"></div>
|
||||||
|
<div class="status" id="state">—</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
<section class="player-card">
|
||||||
|
<div class="artwork" id="artwork">
|
||||||
|
<!-- Placeholder artwork -->
|
||||||
|
<svg viewBox="0 0 100 100" class="art-svg" aria-hidden="true">
|
||||||
|
<rect x="0" y="0" width="100" height="100" fill="#e9f0ff"></rect>
|
||||||
|
<circle cx="50" cy="40" r="18" fill="#cfe0ff"></circle>
|
||||||
|
<rect x="20" y="65" width="60" height="8" rx="2" fill="#cfe0ff"></rect>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<div class="big-title" id="stateTitle">—</div>
|
||||||
|
|
||||||
|
<div class="control-row">
|
||||||
|
<button class="icon-btn prev-button" title="Previous" onclick="simpleGetCall('previous');">
|
||||||
|
<svg width="36" height="36" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M11 19V5L4 12l7 7zM20 5v14h-2V5h2z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="icon-btn play-button" title="Play / Pause" onclick="simpleGetCall('toggleplaypause');">
|
||||||
|
<svg class="play-icon" width="40" height="40" viewBox="0 0 24 24" fill="currentColor"><path d="M5 3v18l15-9L5 3z"/></svg>
|
||||||
|
<svg class="pause-icon" width="40" height="40" viewBox="0 0 24 24" fill="currentColor" style="display:none"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="icon-btn next-button" title="Next" onclick="simpleGetCall('next');">
|
||||||
|
<svg width="36" height="36" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M13 5v14l7-7-7-7zM4 5v14h2V5H4z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="slider-row">
|
||||||
|
<div class="slidecontainer">
|
||||||
|
<input name="progress" type="range" min="0" max="100" value="0" class="slider" id="progressSlider"
|
||||||
|
onchange="postValue('progress',document.getElementById('progressSlider').value);lastChange = Date.now();userIsInteracting = false;"
|
||||||
|
oninput="userIsInteracting = true;">
|
||||||
|
<div class="time-row">
|
||||||
|
<span id="progressLabel">0</span>
|
||||||
|
<span class="time-sep">/</span>
|
||||||
|
<span id="progressMax">0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="volumecontainer">
|
||||||
|
<label for="volumeSlider">Vol</label>
|
||||||
|
<input name="volume" type="range" min="0" max="15" value="7" class="slider" id="volumeSlider"
|
||||||
|
onchange="postValue('volume',document.getElementById('volumeSlider').value);lastChange = Date.now()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="playlist">
|
||||||
|
<div class="playlist-header">
|
||||||
|
<h2>Playlist</h2>
|
||||||
|
<button class="action-btn small" onclick="location.reload()">Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div class="playlist-container" id="playlistContainer"></div>
|
||||||
|
|
||||||
|
<div class="manager-toggle">
|
||||||
|
<button id="toggleFileManagerButton" class="action-btn" onclick="toggleFileManager()">Toggle Manager</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="fileManager" class="file-manager" style="display:none;">
|
||||||
|
<h3>Manager</h3>
|
||||||
|
<div class="info-row">
|
||||||
|
<div id="voltage"></div>
|
||||||
|
<div id="uid"></div>
|
||||||
|
<div id="heap"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4>Upload File</h4>
|
||||||
|
<form id="uploadForm" class="form" method="POST" action="/upload" enctype="multipart/form-data">
|
||||||
|
<input type="file" name="data" id="uploadFile" accept=".mp3,.wav,.flac,.m4a,.ogg"/>
|
||||||
|
<input type="submit" name="upload" value="Upload" title="Upload Audio File" id="uploadButton" class="action-btn"/>
|
||||||
|
<div id="uploadStatus"></div>
|
||||||
|
<div id="uploadProgress" style="display: none;">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" id="progressFill"></div>
|
||||||
|
</div>
|
||||||
|
<span id="progressText">0</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h4>Edit RFID Mapping</h4>
|
||||||
|
<p class="hint">Hint: Use a folder or filename, not the absolute file path!</p>
|
||||||
|
<div class="mapping-list" id="mappingList"></div>
|
||||||
|
|
||||||
|
<form id="editMappingForm" class="form form-grid">
|
||||||
|
<div>
|
||||||
|
<label for="rfid">RFID:</label>
|
||||||
|
<input type="text" id="rfid" name="rfid" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="song">Song:</label>
|
||||||
|
<input type="text" id="song" name="song" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="mode">Mode:</label>
|
||||||
|
<select id="mode" name="mode">
|
||||||
|
<option value="f">Folder (play selected folder, then stop)</option>
|
||||||
|
<option value="s">Single (play selected song / file)</option>
|
||||||
|
<option value="r">Random (randomize order in folder, then stop)</option>
|
||||||
|
<option value="c">Continuous (continuous playback / loop folder)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="action-btn" style="grid-column: 1 / -1;" onclick="editMapping()">Update Mapping</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h4>Move / Rename File</h4>
|
||||||
|
<form class="form form-grid">
|
||||||
|
<div>
|
||||||
|
<label for="moveFrom">From:</label>
|
||||||
|
<input type="text" id="moveFrom" placeholder="/oldname.mp3"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="moveTo">To:</label>
|
||||||
|
<input type="text" id="moveTo" placeholder="/newname.mp3"/>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="action-btn" style="grid-column: 1 / -1;" onclick="moveFile()">Move/Rename</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h4>Delete File</h4>
|
||||||
|
<form class="form form-grid">
|
||||||
|
<div>
|
||||||
|
<label for="deleteFileName">Filename:</label>
|
||||||
|
<input type="text" id="deleteFileName" placeholder="/song.mp3"/>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="action-btn" style="grid-column: 1 / -1;" onclick="deleteFileOnServer()">Delete</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h4>System</h4>
|
||||||
|
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
||||||
|
<button class="action-btn" style="background-color: #ef4444;" onclick="resetWifi()">Reset WiFi Settings</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<div>Built on ESP32 • hannabox</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="script.js"></script>
|
||||||
|
<script>
|
||||||
|
// Keep play/pause icon in sync with the existing class-based logic
|
||||||
|
// Toggle visibility of play/pause SVGs based on .paused class from displayState()
|
||||||
|
function syncPlayIcon() {
|
||||||
|
var btn = document.querySelector('.play-button');
|
||||||
|
if (!btn) return;
|
||||||
|
var playIcon = btn.querySelector('.play-icon');
|
||||||
|
var pauseIcon = btn.querySelector('.pause-icon');
|
||||||
|
if (btn.classList.contains('paused')) {
|
||||||
|
playIcon.style.display = 'none';
|
||||||
|
pauseIcon.style.display = '';
|
||||||
|
} else {
|
||||||
|
playIcon.style.display = '';
|
||||||
|
pauseIcon.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Observe mutations to class attribute on play-button to update icons
|
||||||
|
var observer = new MutationObserver(syncPlayIcon);
|
||||||
|
var playBtn = document.querySelector('.play-button');
|
||||||
|
if (playBtn) observer.observe(playBtn, { attributes: true, attributeFilter: ['class'] });
|
||||||
|
|
||||||
|
// Also call regularly to catch updates
|
||||||
|
setInterval(syncPlayIcon, 800);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
538
web/script.js
Normal file
538
web/script.js
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
setInterval(getState, 4000);
|
||||||
|
setInterval(updateProgress, 500); // Update progress every second
|
||||||
|
|
||||||
|
/* Global single-flight queue for XMLHttpRequest
|
||||||
|
- Serializes all XHR to 1 at a time
|
||||||
|
- Adds timeouts (GET ~3.5s, POST ~6s, /upload 10min)
|
||||||
|
- Deduplicates idempotent GETs to same URL (drops duplicates)
|
||||||
|
This reduces concurrent load on the ESP32 web server and SD card. */
|
||||||
|
(function(){
|
||||||
|
var origOpen = XMLHttpRequest.prototype.open;
|
||||||
|
var origSend = XMLHttpRequest.prototype.send;
|
||||||
|
|
||||||
|
var queue = [];
|
||||||
|
var active = null;
|
||||||
|
var inflightKeys = new Set();
|
||||||
|
|
||||||
|
function keyOf(xhr){ return (xhr.__method || 'GET') + ' ' + (xhr.__url || ''); }
|
||||||
|
|
||||||
|
function startNext(){
|
||||||
|
if (active || queue.length === 0) return;
|
||||||
|
var item = queue.shift();
|
||||||
|
active = item;
|
||||||
|
inflightKeys.add(item.key);
|
||||||
|
|
||||||
|
var xhr = item.xhr;
|
||||||
|
var timeoutMs = item.timeoutMs;
|
||||||
|
var timer = null;
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
active = null;
|
||||||
|
inflightKeys.delete(item.key);
|
||||||
|
if (timer) { clearTimeout(timer); timer = null; }
|
||||||
|
setTimeout(startNext, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
xhr.addEventListener('loadend', cleanup);
|
||||||
|
|
||||||
|
if (timeoutMs > 0 && !xhr.__skipTimeout) {
|
||||||
|
timer = setTimeout(function(){
|
||||||
|
try { xhr.abort(); } catch(e){}
|
||||||
|
}, timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
item.origSend.call(xhr, item.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
XMLHttpRequest.prototype.open = function(method, url, async){
|
||||||
|
this.__method = (method || 'GET').toUpperCase();
|
||||||
|
this.__url = url || '';
|
||||||
|
return origOpen.apply(this, arguments);
|
||||||
|
};
|
||||||
|
|
||||||
|
XMLHttpRequest.prototype.send = function(body){
|
||||||
|
var key = keyOf(this);
|
||||||
|
var isIdempotentGET = (this.__method === 'GET');
|
||||||
|
|
||||||
|
var timeoutMs;
|
||||||
|
if ((this.__url || '').indexOf('/upload') !== -1) {
|
||||||
|
timeoutMs = 600000; // 10 minutes for uploads
|
||||||
|
} else if ((this.__url || '').indexOf('/directory') !== -1) {
|
||||||
|
timeoutMs = 30000; // 30 seconds for directory listing (can be large)
|
||||||
|
} else if (this.__method === 'GET') {
|
||||||
|
timeoutMs = 6000;
|
||||||
|
} else {
|
||||||
|
timeoutMs = 8000;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isIdempotentGET && inflightKeys.has(key)) {
|
||||||
|
// Drop duplicate GET to same resource
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isIdempotentGET) {
|
||||||
|
for (var i = 0; i < queue.length; i++) {
|
||||||
|
if (queue[i].key === key) {
|
||||||
|
// Already queued; keep most recent body if any
|
||||||
|
queue[i].body = body;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var item = { xhr: this, body: body, key: key, timeoutMs: timeoutMs, origSend: origSend };
|
||||||
|
queue.push(item);
|
||||||
|
startNext();
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
/* Dynamic content loaders for playlist and mapping (avoid heavy template processing on server) */
|
||||||
|
function bindPlaylistClicks() {
|
||||||
|
var container = document.getElementById('playlistContainer');
|
||||||
|
if (!container) return;
|
||||||
|
container.onclick = function(e) {
|
||||||
|
var li = e.target.closest('li');
|
||||||
|
if (!li || !container.contains(li)) return;
|
||||||
|
var id = li.dataset && li.dataset.id;
|
||||||
|
if (id) playSongById(id);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadDirectory() {
|
||||||
|
var container = document.getElementById('playlistContainer');
|
||||||
|
if (!container) return;
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('GET', '/directory', true);
|
||||||
|
xhr.onreadystatechange = function() {
|
||||||
|
if (xhr.readyState === 4) {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
container.innerHTML = xhr.responseText || '';
|
||||||
|
bindPlaylistClicks();
|
||||||
|
} else {
|
||||||
|
container.innerHTML = '<div class="hint">Failed to load playlist.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.send();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetWifi() {
|
||||||
|
if (!confirm('Are you sure you want to reset WiFi settings? The device will restart and create an access point.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', '/reset_wifi', true);
|
||||||
|
xhr.onreadystatechange = function() {
|
||||||
|
if (xhr.readyState === 4) {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
alert('WiFi settings reset. Device is restarting...');
|
||||||
|
} else {
|
||||||
|
alert('Reset failed: ' + (xhr.responseText || 'Unknown error'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.send();
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMapping() {
|
||||||
|
var el = document.getElementById('mappingList');
|
||||||
|
if (!el) return;
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('GET', '/mapping', true);
|
||||||
|
xhr.onreadystatechange = function() {
|
||||||
|
if (xhr.readyState === 4) {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
el.innerHTML = xhr.responseText || '';
|
||||||
|
} else {
|
||||||
|
el.innerHTML = '<div class="hint">Failed to load mapping.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.send();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Kick off dynamic loads on DOM ready */
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
loadDirectory();
|
||||||
|
loadMapping();
|
||||||
|
getState();
|
||||||
|
});
|
||||||
|
|
||||||
|
var lastChange = 0;
|
||||||
|
|
||||||
|
var lastStateUpdateTime = Date.now();
|
||||||
|
var songStartTime = 0;
|
||||||
|
var currentSongLength = 0;
|
||||||
|
var isPlaying = false;
|
||||||
|
var userIsInteracting = false; // Flag to track user interaction with the slider
|
||||||
|
|
||||||
|
// Format seconds into mm:ss or hh:mm:ss when needed
|
||||||
|
function formatTime(totalSec) {
|
||||||
|
totalSec = Number.isFinite(totalSec) ? Math.max(0, Math.floor(totalSec)) : 0;
|
||||||
|
var h = Math.floor(totalSec / 3600);
|
||||||
|
var m = Math.floor((totalSec % 3600) / 60);
|
||||||
|
var s = totalSec % 60;
|
||||||
|
function pad(n) { return n < 10 ? '0' + n : '' + n; }
|
||||||
|
if (h > 0) return h + ':' + pad(m) + ':' + pad(s);
|
||||||
|
return m + ':' + pad(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add click event listener to each <li> element
|
||||||
|
/* Clicks are handled via event delegation in bindPlaylistClicks() */
|
||||||
|
|
||||||
|
function simpleGetCall(endpoint) {
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open("GET", "/" + endpoint, true);
|
||||||
|
|
||||||
|
xhr.onreadystatechange = function() {
|
||||||
|
if (xhr.readyState === 4) {
|
||||||
|
getState(); // Fetch the latest state right after the button action
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.send();
|
||||||
|
}
|
||||||
|
|
||||||
|
function postValue(endpoint,value) {
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open("POST", "/" + endpoint, true);
|
||||||
|
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8");
|
||||||
|
xhr.send("value="+encodeURIComponent(value));
|
||||||
|
|
||||||
|
xhr.onreadystatechange = function() {
|
||||||
|
if (xhr.readyState === 4) {
|
||||||
|
getState(); // Fetch the latest state right after the button action
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getState() {
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.onreadystatechange = function() {
|
||||||
|
if (xhr.readyState === 4) {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
try {
|
||||||
|
var state = JSON.parse(xhr.responseText || xhr.response || '{}');
|
||||||
|
isPlaying = !!state['playing'];
|
||||||
|
if (isPlaying) {
|
||||||
|
songStartTime = Date.now() - ((state['time'] || 0) * 1000);
|
||||||
|
currentSongLength = ((state['length'] || 0) * 1000);
|
||||||
|
}
|
||||||
|
lastStateUpdateTime = Date.now();
|
||||||
|
displayState(state);
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore parse errors; will retry on next poll
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.open("GET","/state", true);
|
||||||
|
xhr.send();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProgress() {
|
||||||
|
if (isPlaying && !userIsInteracting) { // Check if user is not interacting
|
||||||
|
var elapsedTime = Date.now() - songStartTime;
|
||||||
|
if (elapsedTime >= currentSongLength) {
|
||||||
|
elapsedTime = currentSongLength;
|
||||||
|
isPlaying = false; // Stop updating if the song has ended
|
||||||
|
}
|
||||||
|
var progressElement = document.getElementById('progressSlider');
|
||||||
|
progressElement.value = elapsedTime / 1000; // Convert to seconds
|
||||||
|
var seconds = Math.floor(elapsedTime / 1000);
|
||||||
|
document.getElementById("progressLabel").innerHTML = formatTime(seconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayState(state) {
|
||||||
|
var title = state['title'] || '—';
|
||||||
|
var titleEl = document.getElementById("state");
|
||||||
|
if (titleEl) titleEl.innerHTML = title;
|
||||||
|
var bigTitleEl = document.getElementById("stateTitle");
|
||||||
|
if (bigTitleEl) bigTitleEl.innerText = title;
|
||||||
|
|
||||||
|
var progressLabel = document.getElementById("progressLabel");
|
||||||
|
if (progressLabel) progressLabel.innerHTML = formatTime(Math.floor(state['time'] || 0));
|
||||||
|
|
||||||
|
var progressMax = document.getElementById("progressMax");
|
||||||
|
if (progressMax) progressMax.innerHTML = formatTime(Math.floor(state['length'] || 0));
|
||||||
|
|
||||||
|
var voltageEl = document.getElementById("voltage");
|
||||||
|
if (voltageEl) voltageEl.innerHTML = (state['voltage'] || '') + ' mV';
|
||||||
|
|
||||||
|
// Update header battery indicator
|
||||||
|
var headerBattery = document.getElementById("batteryStatus");
|
||||||
|
if (headerBattery) {
|
||||||
|
var mv = state['voltage'] || 0;
|
||||||
|
if (mv > 0) {
|
||||||
|
// Estimate percentage for single cell LiPo (approx 3.3V - 4.2V)
|
||||||
|
var pct = Math.round((mv - 3300) / (4200 - 3300) * 100);
|
||||||
|
if (pct < 0) pct = 0;
|
||||||
|
if (pct > 100) pct = 100;
|
||||||
|
|
||||||
|
headerBattery.innerHTML =
|
||||||
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" style="opacity:0.7"><path d="M16 4h-1V2a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v2H8a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z"/></svg>' +
|
||||||
|
'<span>' + pct + '%</span>';
|
||||||
|
headerBattery.title = mv + ' mV';
|
||||||
|
} else {
|
||||||
|
headerBattery.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var heapEl = document.getElementById("heap");
|
||||||
|
if (heapEl) heapEl.innerHTML = (state['heap'] || '') + ' bytes free heap';
|
||||||
|
|
||||||
|
var uidEl = document.getElementById("uid");
|
||||||
|
if (uidEl) uidEl.innerHTML = 'Last NFC ID: ' + (state['uid'] || '');
|
||||||
|
|
||||||
|
/* Autofill convenience fields */
|
||||||
|
var fm = document.getElementById('fileManager');
|
||||||
|
if (state['filepath'] && fm && fm.style.display == 'none') {
|
||||||
|
var moveFrom = document.getElementById('moveFrom');
|
||||||
|
var deleteFileName = document.getElementById('deleteFileName');
|
||||||
|
var song = document.getElementById('song');
|
||||||
|
if (moveFrom) moveFrom.value = state['filepath'];
|
||||||
|
if (deleteFileName) deleteFileName.value = state['filepath'];
|
||||||
|
if (song) song.value = state['filepath'];
|
||||||
|
}
|
||||||
|
if (state['uid']) {
|
||||||
|
var rfidEl = document.getElementById('rfid');
|
||||||
|
if (rfidEl) rfidEl.value = state['uid'];
|
||||||
|
}
|
||||||
|
|
||||||
|
var btn = document.querySelector('.play-button');
|
||||||
|
if (btn) {
|
||||||
|
if (state['playing']) {
|
||||||
|
btn.classList.add('paused');
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('paused');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Date.now()-lastChange>1200) {
|
||||||
|
var progress = document.getElementById('progressSlider');
|
||||||
|
if (progress) {
|
||||||
|
progress.value = state['time'];
|
||||||
|
progress.max = state['length'];
|
||||||
|
}
|
||||||
|
|
||||||
|
var volume = document.getElementById('volumeSlider');
|
||||||
|
if (volume) volume.value = state['volume'];
|
||||||
|
}
|
||||||
|
updateProgress();
|
||||||
|
}
|
||||||
|
|
||||||
|
function playSongById(id) {
|
||||||
|
var url = "/playbyid";
|
||||||
|
var params = "id="+id;
|
||||||
|
var http = new XMLHttpRequest();
|
||||||
|
|
||||||
|
http.open("GET", url+"?"+params, true);
|
||||||
|
http.onreadystatechange = function()
|
||||||
|
{
|
||||||
|
if(http.readyState == 4 && http.status == 200) {
|
||||||
|
getState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
http.send(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function playNamedSong(song) {
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open("POST", "/playnamed");
|
||||||
|
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8");
|
||||||
|
var body = song;
|
||||||
|
xhr.send("title="+encodeURIComponent(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
function editMapping() {
|
||||||
|
var rfid = document.getElementById('rfid').value;
|
||||||
|
var song = document.getElementById('song').value;
|
||||||
|
var modeEl = document.getElementById('mode');
|
||||||
|
var mode = modeEl ? modeEl.value : 's';
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open("POST", "/edit_mapping", true);
|
||||||
|
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8");
|
||||||
|
xhr.send("rfid=" + encodeURIComponent(rfid) + "&song=" + encodeURIComponent(song) + "&mode=" + encodeURIComponent(mode));
|
||||||
|
xhr.onreadystatechange = function() {
|
||||||
|
if (xhr.readyState === 4 && xhr.status === 200) {
|
||||||
|
alert("Mapping updated successfully!");
|
||||||
|
} else if (xhr.readyState === 4) {
|
||||||
|
alert("Failed to update mapping: " + (xhr.responseText || xhr.status));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file before upload
|
||||||
|
function validateFile(file) {
|
||||||
|
var maxSize = 50 * 1024 * 1024; // 50MB limit
|
||||||
|
var allowedExtensions = ['.mp3'];
|
||||||
|
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
return 'File too large. Maximum size is 50MB.';
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileName = file.name.toLowerCase();
|
||||||
|
var hasValidExtension = allowedExtensions.some(ext => fileName.endsWith(ext));
|
||||||
|
|
||||||
|
if (!hasValidExtension) {
|
||||||
|
return 'Invalid file type';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null; // No error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle form submission with AJAX to show upload status
|
||||||
|
document.getElementById('uploadForm').addEventListener('submit', function(event) {
|
||||||
|
event.preventDefault(); // Prevent the default form submit
|
||||||
|
|
||||||
|
var fileInput = document.getElementById('uploadFile');
|
||||||
|
var file = fileInput.files[0];
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
alert('Please select a file to upload.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var validationError = validateFile(file);
|
||||||
|
if (validationError) {
|
||||||
|
alert(validationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var form = event.target;
|
||||||
|
var formData = new FormData(form);
|
||||||
|
var uploadButton = document.getElementById('uploadButton');
|
||||||
|
var uploadStatus = document.getElementById('uploadStatus');
|
||||||
|
var uploadProgress = document.getElementById('uploadProgress');
|
||||||
|
var progressFill = document.getElementById('progressFill');
|
||||||
|
var progressText = document.getElementById('progressText');
|
||||||
|
|
||||||
|
// Disable upload button and show progress
|
||||||
|
uploadButton.disabled = true;
|
||||||
|
uploadButton.value = 'Uploading...';
|
||||||
|
uploadProgress.style.display = 'block';
|
||||||
|
uploadStatus.innerHTML = 'Preparing upload...';
|
||||||
|
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', '/upload', true);
|
||||||
|
|
||||||
|
xhr.upload.onloadstart = function() {
|
||||||
|
uploadStatus.innerHTML = 'Upload started...';
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.upload.onprogress = function(event) {
|
||||||
|
if (event.lengthComputable) {
|
||||||
|
var percentComplete = Math.round((event.loaded / event.total) * 100);
|
||||||
|
progressFill.style.width = percentComplete + '%';
|
||||||
|
progressText.innerHTML = percentComplete + '%';
|
||||||
|
uploadStatus.innerHTML = 'Uploading: ' + percentComplete + '% (' +
|
||||||
|
Math.round(event.loaded / 1024) + 'KB / ' +
|
||||||
|
Math.round(event.total / 1024) + 'KB)';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.upload.onerror = function() {
|
||||||
|
uploadStatus.innerHTML = 'Upload failed due to network error.';
|
||||||
|
resetUploadForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.upload.onabort = function() {
|
||||||
|
uploadStatus.innerHTML = 'Upload was cancelled.';
|
||||||
|
resetUploadForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onreadystatechange = function() {
|
||||||
|
if (xhr.readyState === 4) { // Request is done
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) { // Success status code range
|
||||||
|
uploadStatus.innerHTML = 'Upload completed successfully!';
|
||||||
|
progressFill.style.width = '100%';
|
||||||
|
progressText.innerHTML = '100%';
|
||||||
|
|
||||||
|
setTimeout(function() {
|
||||||
|
alert('File uploaded successfully!');
|
||||||
|
location.reload(); // Reload to get updated playlist
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
var errorMsg = xhr.responseText || 'Unknown error occurred';
|
||||||
|
uploadStatus.innerHTML = 'Upload failed: ' + errorMsg;
|
||||||
|
alert('Upload failed: ' + errorMsg);
|
||||||
|
resetUploadForm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.send(formData); // Send the form data using XMLHttpRequest
|
||||||
|
});
|
||||||
|
|
||||||
|
function resetUploadForm() {
|
||||||
|
var uploadButton = document.getElementById('uploadButton');
|
||||||
|
var uploadProgress = document.getElementById('uploadProgress');
|
||||||
|
var progressFill = document.getElementById('progressFill');
|
||||||
|
var progressText = document.getElementById('progressText');
|
||||||
|
|
||||||
|
uploadButton.disabled = false;
|
||||||
|
uploadButton.value = 'Upload';
|
||||||
|
uploadProgress.style.display = 'none';
|
||||||
|
progressFill.style.width = '0%';
|
||||||
|
progressText.innerHTML = '0%';
|
||||||
|
|
||||||
|
// Clear file input
|
||||||
|
document.getElementById('uploadFile').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* File Manager Functions */
|
||||||
|
|
||||||
|
function toggleFileManager() {
|
||||||
|
var fm = document.getElementById('fileManager');
|
||||||
|
if (fm.style.display === 'none' || fm.style.display === '') {
|
||||||
|
fm.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
fm.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveFile() {
|
||||||
|
var from = document.getElementById('moveFrom').value.trim();
|
||||||
|
var to = document.getElementById('moveTo').value.trim();
|
||||||
|
if (!from || !to) {
|
||||||
|
alert('Please provide both source and destination paths.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('GET', '/move_file?from=' + encodeURIComponent(from) + '&to=' + encodeURIComponent(to), true);
|
||||||
|
xhr.onreadystatechange = function() {
|
||||||
|
if (xhr.readyState === 4) {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
alert('File moved successfully.');
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Move failed: ' + (xhr.responseText || 'Unknown error'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.send();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteFileOnServer() {
|
||||||
|
var filename = document.getElementById('deleteFileName').value.trim();
|
||||||
|
if (!filename) {
|
||||||
|
alert('Please provide filename to delete.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!confirm('Are you sure you want to delete ' + filename + '?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('GET', '/delete_file?filename=' + encodeURIComponent(filename), true);
|
||||||
|
xhr.onreadystatechange = function() {
|
||||||
|
if (xhr.readyState === 4) {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
alert('File deleted successfully.');
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Delete failed: ' + (xhr.responseText || 'Unknown error'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.send();
|
||||||
|
}
|
||||||
524
web/style.css
Normal file
524
web/style.css
Normal file
@@ -0,0 +1,524 @@
|
|||||||
|
/* Theme variables */
|
||||||
|
:root{
|
||||||
|
--bg: #f4f6fb;
|
||||||
|
--card: #ffffff;
|
||||||
|
--muted: #6b7280;
|
||||||
|
--accent: #2563eb;
|
||||||
|
--accent-dark: #1649b0;
|
||||||
|
--glass: rgba(255,255,255,0.6);
|
||||||
|
--radius: 12px;
|
||||||
|
--shadow: 0 6px 18px rgba(18, 38, 63, 0.08);
|
||||||
|
--max-width: 1100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page basics */
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html,body {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
font-family: Inter, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
background: linear-gradient(180deg, var(--bg), #eef3fb 120%);
|
||||||
|
color: #1f2937;
|
||||||
|
-webkit-font-smoothing:antialiased;
|
||||||
|
-moz-osx-font-smoothing:grayscale;
|
||||||
|
overflow-x: hidden; /* prevent accidental horizontal scroll on mobile */
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
max-width: var(--max-width);
|
||||||
|
margin: 18px auto 0 auto;
|
||||||
|
padding: 12px 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
object-fit: contain;
|
||||||
|
filter: drop-shadow(0 4px 10px rgba(37,99,235,0.12));
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar .sub {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battery-status {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: rgba(255,255,255,0.5);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status (current song) */
|
||||||
|
.status {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
text-align: right;
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main layout */
|
||||||
|
.container {
|
||||||
|
max-width: var(--max-width);
|
||||||
|
margin: 18px auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr; /* single column layout: player spans full width, playlist below */
|
||||||
|
gap: 20px;
|
||||||
|
padding: 12px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Player card - grid with fixed artwork column and a fluid content column */
|
||||||
|
.player-card {
|
||||||
|
background: linear-gradient(180deg, rgba(255,255,255,0.9), var(--card));
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 18px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 180px 1fr;
|
||||||
|
grid-auto-rows: min-content;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: start;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
min-height: 150px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Artwork */
|
||||||
|
.artwork {
|
||||||
|
width: 160px;
|
||||||
|
height: 160px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(180deg,#e9f3ff,#dbefff);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 6px 18px rgba(37,99,235,0.06);
|
||||||
|
grid-column: 1 / 2;
|
||||||
|
grid-row: 1 / 3; /* span both rows so artwork stays aligned to left */
|
||||||
|
}
|
||||||
|
|
||||||
|
.art-svg { width: 100%; height: 100%; }
|
||||||
|
|
||||||
|
/* Controls column - use a predictable flex column so controls center nicely */
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
grid-column: 2 / 3;
|
||||||
|
grid-row: 1 / 3;
|
||||||
|
padding-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-title {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons row - span full width of controls grid */
|
||||||
|
.control-row {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
gap: 18px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon button */
|
||||||
|
.icon-btn {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: none;
|
||||||
|
background: linear-gradient(180deg, #ffffff, #f6fbff);
|
||||||
|
display:flex;
|
||||||
|
align-items:center;
|
||||||
|
justify-content:center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform .14s ease, box-shadow .14s ease;
|
||||||
|
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.06);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 10px 26px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* slightly different styling for play (primary) */
|
||||||
|
.play-button {
|
||||||
|
width: 86px;
|
||||||
|
height: 86px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: linear-gradient(180deg, var(--accent), var(--accent-dark));
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 10px 30px rgba(37,99,235,0.18);
|
||||||
|
display:flex;
|
||||||
|
align-items:center;
|
||||||
|
justify-content:center;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon colors inside play */
|
||||||
|
.play-button .play-icon,
|
||||||
|
.play-button .pause-icon {
|
||||||
|
fill: currentColor;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Play/pause toggle class (keeps JS compatibility) */
|
||||||
|
.play-button.paused {
|
||||||
|
background: linear-gradient(180deg, #f3f4f6, #e5e7eb);
|
||||||
|
color: #111827;
|
||||||
|
box-shadow: 0 4px 10px rgba(2,6,23,0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prev / Next buttons - larger and visually consistent with play button */
|
||||||
|
.prev-button, .next-button {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 14px;
|
||||||
|
color: var(--accent);
|
||||||
|
background: linear-gradient(180deg,#ffffff,#f6fbff);
|
||||||
|
box-shadow: 0 8px 20px rgba(37,99,235,0.06);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: transform .14s ease, box-shadow .14s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slider row - kept simple; placement is handled by the .controls grid */
|
||||||
|
.slider-row {
|
||||||
|
display:block;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop: keep internal grid placement but ensure small screens revert */
|
||||||
|
@media (min-width: 921px) {
|
||||||
|
.slider-row {
|
||||||
|
display:block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Small screens: fallback to stacked layout */
|
||||||
|
@media (max-width: 920px) {
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress slider - ensure full width inside controls */
|
||||||
|
.slidecontainer {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 820px;
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider {
|
||||||
|
width: 100%;
|
||||||
|
appearance: none;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(90deg, rgba(37,99,235,0.2), rgba(37,99,235,0.1));
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: white;
|
||||||
|
border: 4px solid var(--accent);
|
||||||
|
box-shadow: 0 4px 14px rgba(37,99,235,0.2);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider::-moz-range-thumb {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: white;
|
||||||
|
border: 4px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* time row */
|
||||||
|
.time-row {
|
||||||
|
display:flex;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted);
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Volume container - on desktop keep it compact and aligned under the progress; on small screens it expands */
|
||||||
|
.volumecontainer {
|
||||||
|
width: 70%;
|
||||||
|
max-width: 420px;
|
||||||
|
min-width: 140px;
|
||||||
|
margin-top: 6px;
|
||||||
|
padding-left: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
/* Ensure the volume label sits above the slider and is aligned */
|
||||||
|
.volumecontainer label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Small screens: make volume full width under the progress slider */
|
||||||
|
@media (max-width: 920px) {
|
||||||
|
.volumecontainer {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On wide screens keep a compact volume control to the right */
|
||||||
|
@media (min-width: 921px) {
|
||||||
|
.volumecontainer {
|
||||||
|
width: 180px;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Playlist aside */
|
||||||
|
.playlist {
|
||||||
|
display:flex;
|
||||||
|
flex-direction:column;
|
||||||
|
gap: 12px;
|
||||||
|
align-self: start; /* ensure sidebar aligns to top of the player card on wide screens */
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-header {
|
||||||
|
display:flex;
|
||||||
|
align-items:center;
|
||||||
|
justify-content:space-between;
|
||||||
|
gap:8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-container {
|
||||||
|
background: var(--card);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
max-height: 420px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden; /* avoid horizontal overflow from long items */
|
||||||
|
border: 1px solid rgba(15,23,42,0.04);
|
||||||
|
position: relative; /* keep content contained */
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Assuming server injects <ul><li data-id="...">Song</li> */
|
||||||
|
.playlist-container ul { margin: 0; padding: 0; list-style: none; }
|
||||||
|
.playlist-container li {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
display:flex;
|
||||||
|
align-items:center;
|
||||||
|
gap:10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .12s ease, transform .08s ease;
|
||||||
|
background: transparent;
|
||||||
|
z-index: 1;
|
||||||
|
overflow: hidden; /* contain long text */
|
||||||
|
word-break: break-word; /* break very long words/paths */
|
||||||
|
overflow-wrap: anywhere; /* allow wrapping anywhere if needed */
|
||||||
|
}
|
||||||
|
.playlist-container li:hover {
|
||||||
|
background: linear-gradient(90deg, rgba(37,99,235,0.04), rgba(37,99,235,0.02));
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
.playlist-container li .title { flex:1; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
|
.playlist-container li .meta { color: var(--muted); font-size: 0.85rem; }
|
||||||
|
|
||||||
|
/* Manager toggle & file manager */
|
||||||
|
.manager-toggle { margin-top: 8px; text-align: center; }
|
||||||
|
.file-manager {
|
||||||
|
margin-top: 12px;
|
||||||
|
background: var(--card);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
border: 1px solid rgba(15,23,42,0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A dedicated mapping list container so injected HTML doesn't inherit .form styling */
|
||||||
|
.mapping-list {
|
||||||
|
background: #fbfdff;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(37,99,235,0.06);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
word-break: break-word; /* wrap long mapping values */
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Align info row */
|
||||||
|
.info-row { display:flex; gap:8px; font-weight:600; color:var(--muted); margin-bottom:8px; flex-wrap:wrap; }
|
||||||
|
|
||||||
|
/* Forms & progress bar (preserve earlier styles) */
|
||||||
|
.form {
|
||||||
|
background-color: white;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 6px rgba(2,6,23,0.04);
|
||||||
|
margin: 10px 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid helper for multi-field forms (move/rename/delete) */
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make .form-grid single-column on small screens */
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid label {
|
||||||
|
margin: 0;
|
||||||
|
font-weight:600;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid input[type="text"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #eef4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure labels are block and inputs full width across all forms */
|
||||||
|
.form label { display:block; margin: 8px 0 6px 0; font-weight:600; color: #334155; }
|
||||||
|
.form input[type="text"],
|
||||||
|
.form input[type="file"],
|
||||||
|
.form input[type="range"] { width: 100%; padding: 8px; border: 1px solid #e6eefc; border-radius: 6px; }
|
||||||
|
|
||||||
|
/* Upload progress bar */
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 12px;
|
||||||
|
background-color: #eef3ff;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--accent), var(--accent-dark));
|
||||||
|
width: 0%;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons (action-btn) */
|
||||||
|
.action-btn {
|
||||||
|
background-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: background-color 0.18s ease, transform 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.small { padding:6px 10px; font-size:13px; }
|
||||||
|
.action-btn:hover:not(:disabled) { background-color: var(--accent-dark); transform: translateY(-2px); }
|
||||||
|
.action-btn:disabled { background-color: #9aa4b2; cursor:not-allowed; }
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.footer {
|
||||||
|
max-width: var(--max-width);
|
||||||
|
margin: 18px auto 36px auto;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive tweaks */
|
||||||
|
@media (max-width: 920px) {
|
||||||
|
.container {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
/* switch to flex to avoid grid min-content overflow on small screens */
|
||||||
|
.player-card { display: flex; flex-direction: column; gap: 14px; padding: 14px; }
|
||||||
|
.artwork { width: 120px; height: 120px; }
|
||||||
|
.play-button { width: 72px; height: 72px; }
|
||||||
|
.playlist { order: 2; }
|
||||||
|
.playlist-container { max-height: 320px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.topbar { padding: 10px; flex-wrap: wrap; }
|
||||||
|
.brand h1 { font-size: 1rem; }
|
||||||
|
.status {
|
||||||
|
min-width: 0; /* allow shrinking */
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1 1 100%;
|
||||||
|
order: 3; /* move status below brand on very small devices */
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.artwork { width: 96px; height: 96px; }
|
||||||
|
.play-button { width: 64px; height: 64px; }
|
||||||
|
.control-row { gap: 8px; }
|
||||||
|
.time-row { font-size: 0.9rem; }
|
||||||
|
.volumecontainer { width: 100%; }
|
||||||
|
.slider-row { flex-direction: column; align-items: stretch; gap: 8px; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user