diff --git a/.gitmodules b/.gitmodules index 7355c0e..e5f52df 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,6 @@ [submodule "device/components/esp-idf-rc522"] path = device/components/esp-idf-rc522 - url = git@github.com:sweidac/esp-idf-rc522.git + url = https://github.com/sweidac/esp-idf-rc522.git [submodule "device/components/arduino-esp32"] path = device/components/arduino-esp32 diff --git a/device/main/AudioPlayer.cpp b/device/main/AudioPlayer.cpp index e24f29f..7ca91a9 100644 --- a/device/main/AudioPlayer.cpp +++ b/device/main/AudioPlayer.cpp @@ -1,24 +1,40 @@ #include "AudioPlayer.hpp" -#include #include #include "audio_setup.h" -#include +#include + +static const char* TAG = "AudioPlayer"; AudioPlayer::AudioPlayer() { playerHandle = setup_player(NULL, NULL); - esp_player_init(playerHandle); - - esp_audio_vol_set(playerHandle, 50); + esp_player_init((esp_audio_handle_t)playerHandle); + esp_audio_vol_set((esp_audio_handle_t)playerHandle, 50); } -void AudioPlayer::play(std::string uri) { - if (isLastUri(uri)) { +bool AudioPlayer::isHttpUri(const std::string& uri) const { + return uri.rfind("http://", 0) == 0 || uri.rfind("https://", 0) == 0; +} + +bool AudioPlayer::isLastUri(const std::string& uri) const { + return lastUri == uri; +} + +void AudioPlayer::play(const std::string& uri) { + if(isLastUri(uri)) { esp_player_music_resume(); + return; + } + + esp_player_music_stop(); + lastUri = uri; + + if(isHttpUri(uri)) { + ESP_LOGI(TAG, "Streaming: %s", uri.c_str()); + esp_player_http_music_play(uri.c_str(), 0); } else { - esp_player_music_stop(); + ESP_LOGI(TAG, "Playing local: %s", uri.c_str()); esp_player_sdcard_music_play(uri.c_str(), 0); - lastUri = std::string(uri); } } @@ -26,6 +42,25 @@ void AudioPlayer::pause() { esp_player_music_pause(); } -bool AudioPlayer::isLastUri(std::string uri) { - return lastUri == uri; -} \ No newline at end of file +void AudioPlayer::stop() { + esp_player_music_stop(); + lastUri.clear(); +} + +int AudioPlayer::getVolume() { + int vol = 0; + esp_player_vol_get(&vol); + return vol; +} + +void AudioPlayer::setVolume(int vol) { + if(vol < 0) vol = 0; + if(vol > 100) vol = 100; + esp_audio_vol_set((esp_audio_handle_t)playerHandle, vol); +} + +bool AudioPlayer::isPlaying() const { + esp_audio_state_t state; + if(esp_player_state_get(&state) != ESP_OK) return false; + return state.status == AUDIO_STATUS_RUNNING; +} diff --git a/device/main/AudioPlayer.hpp b/device/main/AudioPlayer.hpp index cfd5719..12f3ec6 100644 --- a/device/main/AudioPlayer.hpp +++ b/device/main/AudioPlayer.hpp @@ -1,16 +1,29 @@ #pragma once #include +#include class AudioPlayer { - void* playerHandle; - std::string lastUri; + void* playerHandle; + std::string lastUri; - public: - AudioPlayer(); - void play(std::string uri); - void pause(); +public: + AudioPlayer(); - private: - bool isLastUri(std::string uri); -}; \ No newline at end of file + /* Play URI. http:// / https:// URIs stream via HTTP; anything else plays from SD card. + * Re-placing the same URI resumes rather than restarting. */ + void play(const std::string& uri); + + void pause(); + void stop(); + + int getVolume(); + void setVolume(int vol); + + bool isPlaying() const; + const std::string& getCurrentUri() const { return lastUri; } + +private: + bool isHttpUri(const std::string& uri) const; + bool isLastUri(const std::string& uri) const; +}; diff --git a/device/main/BluetoothInterface.cpp b/device/main/BluetoothInterface.cpp new file mode 100644 index 0000000..52360cb --- /dev/null +++ b/device/main/BluetoothInterface.cpp @@ -0,0 +1,25 @@ +#include "BluetoothInterface.hpp" +#include + +static const char* TAG = "Bluetooth"; + +BluetoothInterface::BluetoothInterface() { + ESP_LOGI(TAG, "BT interface created (stub — not yet enabled)"); +} + +bool BluetoothInterface::setEnabled(bool on) { + if(on == enabled) return enabled; + + if(on) { + /* TODO: initialize Bluedroid stack, register A2DP sink callbacks, + * switch audio pipeline output from I2S codec to BT stream. + * Requires CONFIG_BT_ENABLED=y and esp_bt / esp_a2dp components. */ + ESP_LOGW(TAG, "BT enable requested — not yet implemented"); + enabled = false; /* keep false until implemented */ + } else { + ESP_LOGI(TAG, "BT disable requested"); + enabled = false; + } + + return enabled; +} diff --git a/device/main/BluetoothInterface.hpp b/device/main/BluetoothInterface.hpp new file mode 100644 index 0000000..f770ee4 --- /dev/null +++ b/device/main/BluetoothInterface.hpp @@ -0,0 +1,19 @@ +#pragma once + +/* Stub for Bluetooth A2DP speaker mode. + * When enabled the ESP32 advertises itself as a BT audio sink so a phone + * can pair and route music through the Kidsbox speaker. + * Full implementation requires the esp_bt + esp_a2dp components to be + * added to the build and CONFIG_BT_ENABLED=y in sdkconfig. */ +class BluetoothInterface { +public: + BluetoothInterface(); + + bool isEnabled() const { return enabled; } + + /* Toggle BT speaker mode on/off. Returns whether BT is now enabled. */ + bool setEnabled(bool on); + +private: + bool enabled = false; +}; diff --git a/device/main/CMakeLists.txt b/device/main/CMakeLists.txt index f4c64e0..30122c3 100644 --- a/device/main/CMakeLists.txt +++ b/device/main/CMakeLists.txt @@ -1,2 +1,12 @@ -idf_component_register(SRCS "Networking.cpp" "main.cpp" "WebInterface.cpp" "RFIDInterface.cpp" "audio_setup.c" "AudioPlayer.cpp" "TagPlayerControl.cpp" - INCLUDE_DIRS ".") +idf_component_register(SRCS + "main.cpp" + "Networking.cpp" + "WebInterface.cpp" + "RFIDInterface.cpp" + "AudioPlayer.cpp" + "TagPlayerControl.cpp" + "CacheManager.cpp" + "BluetoothInterface.cpp" + "audio_setup.c" + INCLUDE_DIRS "." + REQUIRES esp_http_client esp_netif) diff --git a/device/main/CacheManager.cpp b/device/main/CacheManager.cpp new file mode 100644 index 0000000..e293073 --- /dev/null +++ b/device/main/CacheManager.cpp @@ -0,0 +1,205 @@ +#include "CacheManager.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static const char* TAG = "CacheManager"; + +struct DownloadArgs { + std::string url; + std::function onComplete; +}; + +CacheManager::CacheManager() { + mkdir(CACHE_DIR, 0777); +} + +/* djb2 hash of the URL, returned as 8-char hex string */ +std::string CacheManager::urlToKey(const std::string& url) const { + uint32_t hash = 5381; + for(char c : url) { + hash = ((hash << 5) + hash) ^ (uint8_t)c; + } + char buf[9]; + snprintf(buf, sizeof(buf), "%08lx", (unsigned long)hash); + return std::string(buf); +} + +std::string CacheManager::urlExtension(const std::string& url) const { + /* Strip query/fragment before looking for extension */ + size_t qs = url.find('?'); + std::string path = (qs != std::string::npos) ? url.substr(0, qs) : url; + + size_t dot = path.rfind('.'); + size_t slash = path.rfind('/'); + + if(dot != std::string::npos && (slash == std::string::npos || dot > slash)) { + std::string ext = path.substr(dot); /* includes the dot */ + if(ext.size() <= 5) return ext; /* ignore suspiciously long "extensions" */ + } + return ".mp3"; /* sensible default */ +} + +std::string CacheManager::keyToPath(const std::string& key, const std::string& ext) const { + return std::string(CACHE_DIR) + "/" + key + ext; +} + +/* Find any file in CACHE_DIR whose name starts with key */ +std::string CacheManager::findFileByKey(const std::string& key) const { + DIR* dir = opendir(CACHE_DIR); + if(!dir) return ""; + + struct dirent* entry; + while((entry = readdir(dir)) != NULL) { + std::string name(entry->d_name); + if(name.rfind(key, 0) == 0) { + closedir(dir); + return std::string(CACHE_DIR) + "/" + name; + } + } + closedir(dir); + return ""; +} + +std::string CacheManager::getCachedPath(const std::string& url) const { + if(url.empty()) return ""; + std::string key = urlToKey(url); + std::string path = findFileByKey(key); + if(!path.empty()) { + ESP_LOGI(TAG, "Cache hit: %s -> %s", url.c_str(), path.c_str()); + } + return path; +} + +void CacheManager::downloadTask(void* arg) { + auto* dl = static_cast(arg); + + ESP_LOGI(TAG, "Downloading: %s", dl->url.c_str()); + + CacheManager cm; + std::string key = cm.urlToKey(dl->url); + std::string ext = cm.urlExtension(dl->url); + std::string path = cm.keyToPath(key, ext); + + esp_http_client_config_t cfg = {}; + cfg.url = dl->url.c_str(); + cfg.timeout_ms = 30000; + cfg.buffer_size = 4096; + + esp_http_client_handle_t client = esp_http_client_init(&cfg); + esp_err_t err = esp_http_client_open(client, 0); + + bool success = false; + + if(err == ESP_OK) { + int content_len = esp_http_client_fetch_headers(client); + (void)content_len; + + FILE* f = fopen(path.c_str(), "wb"); + if(f) { + char buf[4096]; + int read_len; + while((read_len = esp_http_client_read(client, buf, sizeof(buf))) > 0) { + fwrite(buf, 1, read_len, f); + } + fclose(f); + success = (read_len == 0); /* 0 = clean EOF */ + if(!success) { + remove(path.c_str()); + ESP_LOGE(TAG, "Download error, partial file deleted"); + } else { + ESP_LOGI(TAG, "Cached to %s", path.c_str()); + } + } else { + ESP_LOGE(TAG, "Cannot open cache file: %s (errno %d)", path.c_str(), errno); + } + } else { + ESP_LOGE(TAG, "HTTP open failed: %s", esp_err_to_name(err)); + } + + esp_http_client_cleanup(client); + + if(dl->onComplete) dl->onComplete(success); + + delete dl; + vTaskDelete(NULL); +} + +void CacheManager::downloadAsync(const std::string& url, std::function onComplete) { + if(url.empty()) return; + + auto* args = new DownloadArgs{ url, onComplete }; + xTaskCreate(downloadTask, "cache_dl", 8192, args, 3, NULL); +} + +std::vector CacheManager::listEntries() const { + std::vector entries; + + DIR* dir = opendir(CACHE_DIR); + if(!dir) return entries; + + struct dirent* entry; + while((entry = readdir(dir)) != NULL) { + if(entry->d_name[0] == '.') continue; + + std::string name(entry->d_name); + std::string full = std::string(CACHE_DIR) + "/" + name; + + struct stat st; + size_t size = 0; + if(stat(full.c_str(), &st) == 0) size = st.st_size; + + /* Key is filename without extension */ + size_t dot = name.rfind('.'); + std::string key = (dot != std::string::npos) ? name.substr(0, dot) : name; + + entries.push_back({ key, /*url unknown*/"", full, size }); + } + closedir(dir); + return entries; +} + +bool CacheManager::deleteEntry(const std::string& key) { + std::string path = findFileByKey(key); + if(path.empty()) return false; + return remove(path.c_str()) == 0; +} + +void CacheManager::deleteAll() { + DIR* dir = opendir(CACHE_DIR); + if(!dir) return; + + struct dirent* entry; + while((entry = readdir(dir)) != NULL) { + if(entry->d_name[0] == '.') continue; + std::string full = std::string(CACHE_DIR) + "/" + entry->d_name; + remove(full.c_str()); + } + closedir(dir); + ESP_LOGI(TAG, "Cache cleared"); +} + +size_t CacheManager::totalBytes() const { + size_t total = 0; + DIR* dir = opendir(CACHE_DIR); + if(!dir) return 0; + + struct dirent* entry; + while((entry = readdir(dir)) != NULL) { + if(entry->d_name[0] == '.') continue; + std::string full = std::string(CACHE_DIR) + "/" + entry->d_name; + struct stat st; + if(stat(full.c_str(), &st) == 0) total += st.st_size; + } + closedir(dir); + return total; +} diff --git a/device/main/CacheManager.hpp b/device/main/CacheManager.hpp new file mode 100644 index 0000000..1fa9e81 --- /dev/null +++ b/device/main/CacheManager.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include + +struct CacheEntry { + std::string key; /* hex hash of the URL */ + std::string url; + std::string path; /* full SD card path */ + size_t size; /* file size in bytes, 0 if unknown */ +}; + +class CacheManager { +public: + CacheManager(); + + /* Returns the SD-card path for url if cached, or empty string if not. */ + std::string getCachedPath(const std::string& url) const; + + /* Enqueue an async FreeRTOS task that downloads url and caches it. + * onComplete is called (from the download task) when done or on error. */ + void downloadAsync(const std::string& url, std::function onComplete = nullptr); + + /* List all cached entries. */ + std::vector listEntries() const; + + /* Delete a single cache entry by key (hex hash). Returns true if deleted. */ + bool deleteEntry(const std::string& key); + + /* Delete the entire cache directory. */ + void deleteAll(); + + /* Total bytes used by cache. */ + size_t totalBytes() const; + +private: + static constexpr const char* CACHE_DIR = "/sdcard/cache"; + + std::string urlToKey(const std::string& url) const; + std::string urlExtension(const std::string& url) const; + std::string keyToPath(const std::string& key, const std::string& ext) const; + std::string findFileByKey(const std::string& key) const; + + static void downloadTask(void* arg); +}; diff --git a/device/main/Networking.cpp b/device/main/Networking.cpp index 0b72c6f..0e04bd4 100644 --- a/device/main/Networking.cpp +++ b/device/main/Networking.cpp @@ -1,15 +1,26 @@ #include "Networking.hpp" #include "WiFi.h" +#include + +static const char* TAG = "Networking"; + +static constexpr int WIFI_TIMEOUT_MS = 30000; +static constexpr int WIFI_POLL_MS = 500; void Networking::connectWifi() { - WiFi.begin(CONFIG_ESP_WIFI_SSID, CONFIG_ESP_WIFI_PASSWORD); + WiFi.begin(CONFIG_ESP_WIFI_SSID, CONFIG_ESP_WIFI_PASSWORD); - while (WiFi.status() != WL_CONNECTED) { - delay(1000); - Serial.println("Waiting for connection..."); + int elapsed = 0; + while(WiFi.status() != WL_CONNECTED && elapsed < WIFI_TIMEOUT_MS) { + vTaskDelay(WIFI_POLL_MS / portTICK_PERIOD_MS); + elapsed += WIFI_POLL_MS; + ESP_LOGI(TAG, "Waiting for WiFi... (%d ms)", elapsed); } - Serial.println("Connected."); - Serial.println(WiFi.localIP()); -} \ No newline at end of file + if(WiFi.status() == WL_CONNECTED) { + ESP_LOGI(TAG, "Connected. IP: %s", WiFi.localIP().toString().c_str()); + } else { + ESP_LOGW(TAG, "WiFi connection timed out — running without network"); + } +} diff --git a/device/main/RFIDInterface.cpp b/device/main/RFIDInterface.cpp index d4c01dd..23e8201 100644 --- a/device/main/RFIDInterface.cpp +++ b/device/main/RFIDInterface.cpp @@ -10,55 +10,81 @@ static const char* TAG = "rfid"; -static char* lastTagId = (char*)malloc(26 * sizeof(char)); -static std::function callback; +static char lastTagId[26]; +static std::function legacyCallback; +static std::function ndefCallback; static char* getLastTagId() { return lastTagId; } +static void onUidCallback(uint8_t* sn) { + if(sn == NULL) { + ESP_LOGI(TAG, "Tag removed"); + lastTagId[0] = '\0'; + if(legacyCallback) legacyCallback(NULL); + } else { + snprintf(lastTagId, sizeof(lastTagId), + "%#x_%#x_%#x_%#x_%#x", sn[0], sn[1], sn[2], sn[3], sn[4]); + ESP_LOGI(TAG, "Tag UID: %s", lastTagId); + if(legacyCallback) legacyCallback(lastTagId); + } +} + +static void onNdefCallback(uint8_t* sn, const char* url) { + if(!ndefCallback) return; + + if(sn == NULL) { + ndefCallback(NULL, NULL); + return; + } + + /* Format UID same way as above */ + char uid[26]; + snprintf(uid, sizeof(uid), + "%#x_%#x_%#x_%#x_%#x", sn[0], sn[1], sn[2], sn[3], sn[4]); + + if(url) { + ESP_LOGI(TAG, "NDEF URL on %s: %s", uid, url); + } else { + ESP_LOGI(TAG, "No NDEF on %s", uid); + } + + ndefCallback(uid, url); +} + RFIDInterface::RFIDInterface() { - startConfig = (rc522_start_args_t){ + startConfig = (rc522_start_args_t){ .miso_io = 12, .mosi_io = 13, - .sck_io = 23, - .sda_io = 18, - .callback = [](uint8_t* sn) { - if (sn == NULL) { - ESP_LOGI(TAG, "Tag removed"); - - if (callback) { - callback(NULL); - } - } else { // serial number is always 5 bytes long - sprintf(lastTagId, "%#x_%#x_%#x_%#x_%#x", sn[0], sn[1], sn[2], sn[3], sn[4]); - ESP_LOGI(TAG, "tag id: %s", lastTagId); - - if (callback) { - callback(getLastTagId()); - } - } - } - }; + .sck_io = 23, + .sda_io = 18, + .spi_host_id = (spi_host_device_t)0, + .callback = onUidCallback, + .ndef_callback = onNdefCallback, + }; } void RFIDInterface::start() { - rc522_start(startConfig); + rc522_start(startConfig); } void RFIDInterface::registerWebResources(WebInterface* interface) { httpd_uri_t config = { - .uri = "/rfid", - .method = HTTP_GET, - .handler = [](httpd_req_t *req) { + .uri = "/rfid", + .method = HTTP_GET, + .handler = [](httpd_req_t* req) { httpd_resp_send(req, getLastTagId(), HTTPD_RESP_USE_STRLEN); return ESP_OK; } }; - interface->registerResource(&config); } +void RFIDInterface::registerTagCallback(std::function cb) { + ndefCallback = cb; +} + void RFIDInterface::registerTagChangeCallback(std::function cb) { - callback = cb; -} \ No newline at end of file + legacyCallback = cb; +} diff --git a/device/main/RFIDInterface.hpp b/device/main/RFIDInterface.hpp index d174967..d6e0811 100644 --- a/device/main/RFIDInterface.hpp +++ b/device/main/RFIDInterface.hpp @@ -6,13 +6,21 @@ #include "WebInterface.hpp" class RFIDInterface { - rc522_start_args_t startConfig; + rc522_start_args_t startConfig; - public: - RFIDInterface(); - void start(); - void registerWebResources(WebInterface* interface); - void registerTagChangeCallback(std::function); +public: + RFIDInterface(); + void start(); + void registerWebResources(WebInterface* interface); + + /* Callback receives (uid_string, url_or_null): + * uid == NULL → tag removed + * url == NULL → tag placed but no NDEF URI found (use uid for local lookup) + * url != NULL → tag placed with NDEF URI */ + void registerTagCallback(std::function cb); + + /* Legacy: only fires with UID string (NULL on removal). Kept for compatibility. */ + void registerTagChangeCallback(std::function cb); }; -#endif \ No newline at end of file +#endif diff --git a/device/main/TagPlayerControl.cpp b/device/main/TagPlayerControl.cpp index 750361c..36472ed 100644 --- a/device/main/TagPlayerControl.cpp +++ b/device/main/TagPlayerControl.cpp @@ -1,62 +1,71 @@ #include "TagPlayerControl.hpp" -#include "AudioPlayer.hpp" - -#include +#include #include #include -#include - -#include static const char* TAG = "TagPlayerControl"; -TagPlayerControl::TagPlayerControl(std::shared_ptr player) - : audioPlayer(player) { +TagPlayerControl::TagPlayerControl(std::shared_ptr player, + std::shared_ptr cache) + : audioPlayer(player), cacheManager(cache) {} + +void TagPlayerControl::onTagChanged(const char* uid, const char* url) { + if(uid == NULL) { + ESP_LOGI(TAG, "Tag removed — pausing"); + audioPlayer->pause(); + lastUid.clear(); + return; + } + + lastUid = std::string(uid); + + if(url != NULL && url[0] != '\0') { + ESP_LOGI(TAG, "Tag %s -> URL: %s", uid, url); + playUrl(std::string(url)); + } else { + ESP_LOGI(TAG, "Tag %s -> no NDEF, trying local mapping", uid); + playLocalTag(std::string(uid)); + } } -void TagPlayerControl::onTagChanged(char* tagId) { - if (tagId == NULL) { - // tag has been removed. stop playback - audioPlayer->pause(); - } else { - playTag(tagId); - } +void TagPlayerControl::playUrl(const std::string& url) { + /* Check cache first; if found play local file to avoid re-downloading */ + std::string cached = cacheManager->getCachedPath(url); + if(!cached.empty()) { + audioPlayer->play(cached); + return; + } + + /* Stream via HTTP and cache in background for future plays */ + audioPlayer->play(url); + + cacheManager->downloadAsync(url, [url](bool success) { + if(success) { + ESP_LOGI("TagPlayerControl", "Cached for next play: %s", url.c_str()); + } + }); } -void TagPlayerControl::playTag(char* tagId) { - std::string filePath = "/sdcard/internal/"; - filePath.append(tagId); +void TagPlayerControl::playLocalTag(const std::string& uid) { + std::string mappingPath = std::string("/sdcard/internal/") + uid; + std::fstream file(mappingPath); - std::fstream file(filePath); + if(!file) { + ESP_LOGW(TAG, "No mapping file for tag %s at %s", uid.c_str(), mappingPath.c_str()); + return; + } - if (! file) { - ESP_LOGE(TAG, "Error opening file for tag %s", tagId); + std::string line, content; + while(getline(file, line)) { + content.append(line); + } + file.close(); - char* error_name; + if(content.empty()) { + ESP_LOGW(TAG, "Empty mapping file for tag %s", uid.c_str()); + return; + } - switch (errno) - { - case EACCES: - ESP_LOGE(TAG, "EACCESS"); - break; - case ENOENT: - ESP_LOGE(TAG, "ENOENT"); - break; - default: - ESP_LOGE(TAG, "not parsed"); - } - return; - } - - std::string line; - std::string content; - - while(getline(file, line)) { - content.append(line); - } - - audioPlayer->play(content); - - file.close(); -} \ No newline at end of file + audioPlayer->play(content); +} diff --git a/device/main/TagPlayerControl.hpp b/device/main/TagPlayerControl.hpp index e24271a..8f823f5 100644 --- a/device/main/TagPlayerControl.hpp +++ b/device/main/TagPlayerControl.hpp @@ -1,16 +1,25 @@ #pragma once #include +#include #include "AudioPlayer.hpp" +#include "CacheManager.hpp" class TagPlayerControl { - std::shared_ptr audioPlayer; + std::shared_ptr audioPlayer; + std::shared_ptr cacheManager; - public: - TagPlayerControl(std::shared_ptr player); + std::string lastUid; - void onTagChanged(char* tagId); - - private: - void playTag(char* tagId); -}; \ No newline at end of file +public: + TagPlayerControl(std::shared_ptr player, + std::shared_ptr cache); + + /* Called by RFIDInterface. uid == NULL means tag removed. + * url is the NDEF URI from the tag, or NULL if no NDEF record was found. */ + void onTagChanged(const char* uid, const char* url); + +private: + void playUrl(const std::string& url); + void playLocalTag(const std::string& uid); +}; diff --git a/device/main/WebInterface.cpp b/device/main/WebInterface.cpp index e15a2db..7f361c9 100644 --- a/device/main/WebInterface.cpp +++ b/device/main/WebInterface.cpp @@ -1,182 +1,290 @@ #include "WebInterface.hpp" +#include "AudioPlayer.hpp" +#include "CacheManager.hpp" #include #include -#include "esp_vfs.h" +#include #include -#include "periph_sdcard.h" -#include - +#include +#include +#include #include -#include "esp_system.h" +#include +#include +#include +#include +#include +#include -static const char *TAG = "WebInterface"; +static const char* TAG = "WebInterface"; -#define FILE_PATH_MAX (ESP_VFS_PATH_MAX + 128) +#define FILE_PATH_MAX (ESP_VFS_PATH_MAX + 128) #define SCRATCH_BUFSIZE (10240) +#define CHECK_FILE_EXTENSION(f, ext) (strcasecmp(&(f)[strlen(f) - strlen(ext)], (ext)) == 0) -#define CHECK_FILE_EXTENSION(filename, ext) (strcasecmp(&filename[strlen(filename) - strlen(ext)], ext) == 0) - -typedef struct rest_server_context { - char base_path[ESP_VFS_PATH_MAX + 1]; - char scratch[SCRATCH_BUFSIZE]; +typedef struct { + char base_path[ESP_VFS_PATH_MAX + 1]; + char scratch[SCRATCH_BUFSIZE]; } rest_server_context_t; -WebInterface::WebInterface() { +/* ── helpers ──────────────────────────────────────────────────────────── */ +static esp_err_t set_content_type(httpd_req_t* req, const char* filepath) { + const char* type = "text/plain"; + if(CHECK_FILE_EXTENSION(filepath, ".html")) type = "text/html"; + else if(CHECK_FILE_EXTENSION(filepath, ".js")) type = "application/javascript"; + else if(CHECK_FILE_EXTENSION(filepath, ".css")) type = "text/css"; + else if(CHECK_FILE_EXTENSION(filepath, ".png")) type = "image/png"; + else if(CHECK_FILE_EXTENSION(filepath, ".ico")) type = "image/x-icon"; + else if(CHECK_FILE_EXTENSION(filepath, ".svg")) type = "image/svg+xml"; + else if(CHECK_FILE_EXTENSION(filepath, ".json"))type = "application/json"; + return httpd_resp_set_type(req, type); } +static void send_json(httpd_req_t* req, const std::string& json) { + httpd_resp_set_type(req, "application/json"); + httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); + httpd_resp_sendstr(req, json.c_str()); +} + +static void send_json_ok(httpd_req_t* req) { + send_json(req, "{\"ok\":true}"); +} + +static void send_json_error(httpd_req_t* req, const char* msg) { + std::string body = std::string("{\"error\":\"") + msg + "\"}"; + httpd_resp_set_status(req, "400 Bad Request"); + send_json(req, body); +} + +/* ── static file handler ──────────────────────────────────────────────── */ + +static esp_err_t rest_common_get_handler(httpd_req_t* req) { + char filepath[FILE_PATH_MAX]; + rest_server_context_t* ctx = (rest_server_context_t*)req->user_ctx; + strlcpy(filepath, ctx->base_path, sizeof(filepath)); + + if(req->uri[strlen(req->uri) - 1] == '/') { + strlcat(filepath, "/index.html", sizeof(filepath)); + } else { + strlcat(filepath, req->uri, sizeof(filepath)); + } + + int fd = open(filepath, O_RDONLY); + if(fd == -1) { + ESP_LOGE(TAG, "Failed to open %s (errno %d)", filepath, errno); + httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "File not found"); + return ESP_FAIL; + } + + set_content_type(req, filepath); + + char* chunk = ctx->scratch; + ssize_t n; + while((n = read(fd, chunk, SCRATCH_BUFSIZE)) > 0) { + if(httpd_resp_send_chunk(req, chunk, n) != ESP_OK) { + close(fd); + httpd_resp_sendstr_chunk(req, NULL); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Send error"); + return ESP_FAIL; + } + } + close(fd); + httpd_resp_send_chunk(req, NULL, 0); + return ESP_OK; +} + +/* ── API handlers (use shared_ptrs captured from WebInterface) ──────── */ + +/* GET /api/info — device info JSON */ +static AudioPlayer* g_audio = nullptr; +static CacheManager* g_cache = nullptr; + +static esp_err_t api_info_handler(httpd_req_t* req) { + /* Get device IP */ + esp_netif_ip_info_t ip_info = {}; + esp_netif_t* netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF"); + if(netif) esp_netif_get_ip_info(netif, &ip_info); + + char ip_str[16] = "0.0.0.0"; + snprintf(ip_str, sizeof(ip_str), IPSTR, IP2STR(&ip_info.ip)); + + int vol = g_audio ? g_audio->getVolume() : 0; + bool playing = g_audio ? g_audio->isPlaying() : false; + size_t cache_bytes = g_cache ? g_cache->totalBytes() : 0; + + /* Free heap */ + uint32_t free_heap = esp_get_free_heap_size(); + + std::ostringstream json; + json << "{" + << "\"ip\":\"" << ip_str << "\"," + << "\"volume\":" << vol << "," + << "\"playing\":" << (playing ? "true" : "false") << "," + << "\"cacheBytes\":" << cache_bytes << "," + << "\"freeHeap\":" << free_heap + << "}"; + + send_json(req, json.str()); + return ESP_OK; +} + +/* GET /api/storage — list cached files as JSON array */ +static esp_err_t api_storage_get_handler(httpd_req_t* req) { + if(!g_cache) { send_json(req, "[]"); return ESP_OK; } + + auto entries = g_cache->listEntries(); + std::ostringstream json; + json << "["; + for(size_t i = 0; i < entries.size(); i++) { + if(i > 0) json << ","; + json << "{\"key\":\"" << entries[i].key << "\"," + << "\"size\":" << entries[i].size << "}"; + } + json << "]"; + send_json(req, json.str()); + return ESP_OK; +} + +/* DELETE /api/storage?key= — delete one entry, or all if no key */ +static esp_err_t api_storage_delete_handler(httpd_req_t* req) { + if(!g_cache) { send_json_ok(req); return ESP_OK; } + + char key_buf[32] = {}; + if(httpd_req_get_url_query_str(req, key_buf, sizeof(key_buf)) == ESP_OK) { + char key[32] = {}; + if(httpd_query_key_value(key_buf, "key", key, sizeof(key)) == ESP_OK && key[0]) { + bool ok = g_cache->deleteEntry(std::string(key)); + if(!ok) { send_json_error(req, "key not found"); return ESP_OK; } + } else { + g_cache->deleteAll(); + } + } else { + g_cache->deleteAll(); + } + send_json_ok(req); + return ESP_OK; +} + +/* POST /api/playback body: action=play|pause|stop|volume&value=<0-100> */ +static esp_err_t api_playback_handler(httpd_req_t* req) { + char buf[128] = {}; + int len = httpd_req_recv(req, buf, sizeof(buf) - 1); + if(len <= 0) { send_json_error(req, "empty body"); return ESP_OK; } + buf[len] = '\0'; + + char action[16] = {}; + httpd_query_key_value(buf, "action", action, sizeof(action)); + + if(!g_audio) { send_json_error(req, "audio not ready"); return ESP_OK; } + + if(strcmp(action, "pause") == 0) { + g_audio->pause(); + } else if(strcmp(action, "stop") == 0) { + g_audio->stop(); + } else if(strcmp(action, "volume") == 0) { + char val_str[8] = {}; + httpd_query_key_value(buf, "value", val_str, sizeof(val_str)); + int vol = atoi(val_str); + g_audio->setVolume(vol); + } else { + send_json_error(req, "unknown action"); + return ESP_OK; + } + + send_json_ok(req); + return ESP_OK; +} + +/* GET /api/bluetooth — returns BT status (stub) */ +static esp_err_t api_bluetooth_get_handler(httpd_req_t* req) { + send_json(req, "{\"enabled\":false,\"note\":\"bluetooth not yet implemented\"}"); + return ESP_OK; +} + +/* POST /api/bluetooth body: enabled=true|false (stub) */ +static esp_err_t api_bluetooth_post_handler(httpd_req_t* req) { + send_json(req, "{\"ok\":false,\"note\":\"bluetooth not yet implemented\"}"); + return ESP_OK; +} + +/* ── WebInterface ────────────────────────────────────────────────────── */ + +WebInterface::WebInterface() {} + void WebInterface::start() { - initSD(); - server = start_webserver(); + initSD(); + server = start_webserver(); } void WebInterface::finishResourceRegistrations() { - registerFileServer(); + registerApiHandlers(); + registerFileServer(); } void WebInterface::initSD() { - esp_periph_config_t periph_cfg = DEFAULT_ESP_PERIPH_SET_CONFIG(); - esp_periph_set_handle_t set = esp_periph_set_init(&periph_cfg); + esp_periph_config_t periph_cfg = DEFAULT_ESP_PERIPH_SET_CONFIG(); + esp_periph_set_handle_t set = esp_periph_set_init(&periph_cfg); - // Initialize SD Card peripheral - if (audio_board_sdcard_init(set, SD_MODE_1_LINE) == ESP_FAIL) { - ESP_LOGE(TAG, "Sdcard not mounted"); - } else { - ESP_LOGI(TAG, "SDCard mounted"); - } + if(audio_board_sdcard_init(set, SD_MODE_1_LINE) == ESP_FAIL) { + ESP_LOGE(TAG, "SDCard not mounted"); + } else { + ESP_LOGI(TAG, "SDCard mounted"); + } } -/* Set HTTP response content type according to file extension */ -esp_err_t set_content_type_from_file(httpd_req_t *req, const char *filepath) -{ - const char *type = "text/plain"; - if (CHECK_FILE_EXTENSION(filepath, ".html")) { - type = "text/html"; - } else if (CHECK_FILE_EXTENSION(filepath, ".js")) { - type = "application/javascript"; - } else if (CHECK_FILE_EXTENSION(filepath, ".css")) { - type = "text/css"; - } else if (CHECK_FILE_EXTENSION(filepath, ".png")) { - type = "image/png"; - } else if (CHECK_FILE_EXTENSION(filepath, ".ico")) { - type = "image/x-icon"; - } else if (CHECK_FILE_EXTENSION(filepath, ".svg")) { - type = "text/xml"; - } - return httpd_resp_set_type(req, type); -} +httpd_handle_t WebInterface::start_webserver() { + httpd_config_t config = HTTPD_DEFAULT_CONFIG(); + config.lru_purge_enable = true; + config.uri_match_fn = httpd_uri_match_wildcard; + config.max_uri_handlers = 16; -/* Send HTTP response with the contents of the requested file */ -esp_err_t rest_common_get_handler(httpd_req_t *req) -{ - char filepath[FILE_PATH_MAX]; + httpd_handle_t srv = NULL; + ESP_LOGI(TAG, "Starting server on port %d", config.server_port); + if(httpd_start(&srv, &config) == ESP_OK) return srv; - rest_server_context_t *rest_context = (rest_server_context_t *)req->user_ctx; - strlcpy(filepath, rest_context->base_path, sizeof(filepath)); - - if (req->uri[strlen(req->uri) - 1] == '/') { - strlcat(filepath, "/index.html", sizeof(filepath)); - } else { - strlcat(filepath, req->uri, sizeof(filepath)); - } - - int fd = open(filepath, O_RDONLY); - if (fd == -1) { - ESP_LOGE(TAG, "Failed to open file : %s. Errno: %d", filepath, errno); - /* Respond with 500 Internal Server Error */ - - char responseError[255]; - char* error_name; - - switch (errno) - { - case EACCES: - error_name = "EACCESS"; - break; - case ENOENT: - error_name = "ENOENT"; - break; - default: - error_name = "not parsed"; - } - - sprintf(responseError, "Failed to read existing file. %d => %s", errno, error_name); - - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, responseError); - return ESP_FAIL; - } - - set_content_type_from_file(req, filepath); - - char *chunk = rest_context->scratch; - ssize_t read_bytes; - do { - /* Read file in chunks into the scratch buffer */ - read_bytes = read(fd, chunk, SCRATCH_BUFSIZE); - if (read_bytes == -1) { - ESP_LOGE(TAG, "Failed to read file : %s", filepath); - } else if (read_bytes > 0) { - /* Send the buffer contents as HTTP response chunk */ - if (httpd_resp_send_chunk(req, chunk, read_bytes) != ESP_OK) { - close(fd); - ESP_LOGE(TAG, "File sending failed!"); - /* Abort sending file */ - httpd_resp_sendstr_chunk(req, NULL); - /* Respond with 500 Internal Server Error */ - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Failed to send file"); - return ESP_FAIL; - } - } - } while (read_bytes > 0); - /* Close file after sending complete */ - close(fd); - ESP_LOGI(TAG, "File sending complete"); - /* Respond with an empty chunk to signal HTTP response completion */ - httpd_resp_send_chunk(req, NULL, 0); - return ESP_OK; -} - - -httpd_handle_t WebInterface::start_webserver(void) { - httpd_handle_t server = NULL; - httpd_config_t config = HTTPD_DEFAULT_CONFIG(); - config.lru_purge_enable = true; - config.uri_match_fn = httpd_uri_match_wildcard; - - // Start the httpd server - ESP_LOGI(TAG, "Starting server on port: '%d'", config.server_port); - if (httpd_start(&server, &config) == ESP_OK) { - return server; - } - - - ESP_LOGI(TAG, "Error starting server!"); - return NULL; + ESP_LOGE(TAG, "Failed to start server"); + return NULL; } void WebInterface::registerResource(httpd_uri_t* config) { - ESP_LOGI(TAG, "Register resource on path %s", config->uri); - httpd_register_uri_handler(server, config); + if(!server) return; + ESP_LOGI(TAG, "Register %s", config->uri); + httpd_register_uri_handler(server, config); +} + +void WebInterface::registerApiHandlers() { + /* Expose singletons to the static C handlers */ + g_audio = audioPlayer.get(); + g_cache = cacheManager.get(); + + httpd_uri_t info = { "/api/info", HTTP_GET, api_info_handler, nullptr }; + httpd_uri_t stg_g = { "/api/storage", HTTP_GET, api_storage_get_handler, nullptr }; + httpd_uri_t stg_d = { "/api/storage", HTTP_DELETE, api_storage_delete_handler, nullptr }; + httpd_uri_t pb = { "/api/playback", HTTP_POST, api_playback_handler, nullptr }; + httpd_uri_t bt_g = { "/api/bluetooth", HTTP_GET, api_bluetooth_get_handler, nullptr }; + httpd_uri_t bt_p = { "/api/bluetooth", HTTP_POST, api_bluetooth_post_handler, nullptr }; + + registerResource(&info); + registerResource(&stg_g); + registerResource(&stg_d); + registerResource(&pb); + registerResource(&bt_g); + registerResource(&bt_p); } void WebInterface::registerFileServer() { - rest_server_context_t *fileContext = (rest_server_context_t*)calloc(1, sizeof(rest_server_context_t)); + rest_server_context_t* ctx = (rest_server_context_t*)calloc(1, sizeof(rest_server_context_t)); + if(!ctx) { ESP_LOGE(TAG, "No memory for file server context"); return; } - if (! fileContext) { - ESP_LOGE(TAG, "No memory for rest context"); - return; - } + strlcpy(ctx->base_path, "/sdcard/www", sizeof(ctx->base_path)); - strlcpy(fileContext->base_path, "/sdcard/www", sizeof(fileContext->base_path)); - - /* URI handler for getting web server files */ - httpd_uri_t common_get_uri = { - .uri = "/*", - .method = HTTP_GET, - .handler = rest_common_get_handler, - .user_ctx = fileContext - }; - - registerResource(&common_get_uri); -} \ No newline at end of file + httpd_uri_t file_uri = { + .uri = "/*", + .method = HTTP_GET, + .handler = rest_common_get_handler, + .user_ctx = ctx + }; + registerResource(&file_uri); +} diff --git a/device/main/WebInterface.hpp b/device/main/WebInterface.hpp index a4acd53..d265994 100644 --- a/device/main/WebInterface.hpp +++ b/device/main/WebInterface.hpp @@ -2,21 +2,32 @@ #define _WEB_INTERFACE_HPP #include +#include +#include + +class AudioPlayer; +class CacheManager; class WebInterface { - httpd_handle_t server = NULL; + httpd_handle_t server = NULL; + std::shared_ptr audioPlayer; + std::shared_ptr cacheManager; - public: - WebInterface(); - void start(); - void finishResourceRegistrations(); - void registerResource(httpd_uri_t* config); - void initSD(); +public: + WebInterface(); - private: - httpd_handle_t start_webserver(); - void registerFileServer(); - //esp_err_t hello_get_handler(httpd_req_t *req); + void setAudioPlayer(std::shared_ptr p) { audioPlayer = p; } + void setCacheManager(std::shared_ptr c) { cacheManager = c; } + + void start(); + void finishResourceRegistrations(); + void registerResource(httpd_uri_t* config); + void initSD(); + +private: + httpd_handle_t start_webserver(); + void registerFileServer(); + void registerApiHandlers(); }; -#endif \ No newline at end of file +#endif diff --git a/device/main/main.cpp b/device/main/main.cpp index f71c5b8..95bd4bd 100644 --- a/device/main/main.cpp +++ b/device/main/main.cpp @@ -5,40 +5,53 @@ #include "RFIDInterface.hpp" #include "AudioPlayer.hpp" #include "TagPlayerControl.hpp" +#include "CacheManager.hpp" +#include "BluetoothInterface.hpp" -#include #include extern "C" { void app_main(); } -Networking networking; -WebInterface webInterface; -RFIDInterface rfid; - -std::shared_ptr audioPlayer; // initialize here, because the ctor does init stuff that fails when executed before `app_main()` +Networking networking; +WebInterface webInterface; +RFIDInterface rfid; +BluetoothInterface bluetooth; +/* Shared objects — must be heap-allocated after app_main() starts the scheduler */ +std::shared_ptr audioPlayer; +std::shared_ptr cacheManager; std::shared_ptr tagPlayerControl; void app_main(void) { initArduino(); + /* SD card must be mounted first — other components depend on it */ webInterface.initSD(); -/* networking.connectWifi(); - webInterface.start(); + /* Network — non-blocking with 30 s timeout */ + networking.connectWifi(); + /* Audio pipeline — must run after scheduler is up */ + audioPlayer = std::make_shared(); + cacheManager = std::make_shared(); + + /* Wire web interface to audio/cache for API endpoints */ + webInterface.setAudioPlayer(audioPlayer); + webInterface.setCacheManager(cacheManager); + + /* Start HTTP server and register API + static-file handlers */ + webInterface.start(); rfid.registerWebResources(&webInterface); webInterface.finishResourceRegistrations(); -*/ - audioPlayer = std::make_shared(); - tagPlayerControl = std::make_shared(audioPlayer); + tagPlayerControl = std::make_shared(audioPlayer, cacheManager); + /* RFID must start after the audio player (hardware init order constraint) */ rfid.start(); - rfid.registerTagChangeCallback([tagPlayerControl](char* tagId) { - tagPlayerControl->onTagChanged(tagId); + rfid.registerTagCallback([](const char* uid, const char* url) { + tagPlayerControl->onTagChanged(uid, url); }); } diff --git a/www/index.html b/www/index.html index 82ba3c3..c7884d4 100644 --- a/www/index.html +++ b/www/index.html @@ -1,17 +1,59 @@ - - - Kidsbox - - - -

Hi, I'm a File!

+ + + + + + Kidsbox + + + +
+

🎧 Kidsbox

+
-
-

RFID-ID

-
- -
+
+
+

Now Playing

+
—
+
+ + +
+
+ + + 50 +
+
- - - \ No newline at end of file +
+

Device Info

+ + + + + +
IP Address—
Status—
Cache Used—
Free Heap—
+ +
+ +
+

Storage

+
+ Loading… + +
+
    +
    + +
    +

    Bluetooth Speaker

    +

    Disabled

    + +

    Pair your phone to play music through the Kidsbox speaker.

    +
    +
    + + + + diff --git a/www/main.js b/www/main.js index 8c01305..0aa9bcc 100644 --- a/www/main.js +++ b/www/main.js @@ -1,13 +1,152 @@ -console.log('hi'); +'use strict'; -const elements = {}; +/* ── helpers ──────────────────────────────────────────────────────────── */ -elements.rfidId = document.querySelector('.rfid-id'); -elements.rfidRefresh = document.querySelector('.rfid-refresh'); +function fmt_bytes(n) { + if (n < 1024) return n + ' B'; + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; + return (n / (1024 * 1024)).toFixed(1) + ' MB'; +} -elements.rfidRefresh.addEventListener('click', async () => { - const response = await fetch('/rfid'), - rfidId = await response.text(); +async function api(method, path, body) { + const opts = { method }; + if (body) { + opts.headers = { 'Content-Type': 'application/x-www-form-urlencoded' }; + opts.body = body; + } + const res = await fetch(path, opts); + return res.json(); +} - elements.rfidId.innerText = rfidId; -}); \ No newline at end of file +/* ── device info ──────────────────────────────────────────────────────── */ + +async function refreshInfo() { + try { + const d = await api('GET', '/api/info'); + document.getElementById('info-ip').textContent = d.ip ?? '—'; + document.getElementById('info-playing').textContent = d.playing ? 'Playing' : 'Stopped'; + document.getElementById('info-cache').textContent = fmt_bytes(d.cacheBytes ?? 0); + document.getElementById('info-heap').textContent = fmt_bytes(d.freeHeap ?? 0); + + document.getElementById('track-uri').textContent = d.currentUri || '—'; + document.getElementById('btn-pause').disabled = !d.playing; + document.getElementById('btn-stop').disabled = !d.playing; + + const vol = d.volume ?? 50; + document.getElementById('volume-slider').value = vol; + document.getElementById('volume-label').textContent = vol; + } catch (e) { + console.error('Failed to fetch /api/info', e); + } +} + +/* ── storage ──────────────────────────────────────────────────────────── */ + +async function refreshStorage() { + try { + const entries = await api('GET', '/api/storage'); + const list = document.getElementById('cache-list'); + const summary = document.getElementById('storage-summary'); + + list.innerHTML = ''; + + if (!Array.isArray(entries) || entries.length === 0) { + summary.textContent = 'No cached files'; + return; + } + + let total = 0; + entries.forEach(e => { + total += e.size ?? 0; + const li = document.createElement('li'); + const key = document.createElement('span'); + key.className = 'cache-key'; + key.textContent = e.key; + const sz = document.createElement('span'); + sz.className = 'cache-size'; + sz.textContent = fmt_bytes(e.size ?? 0); + const del = document.createElement('button'); + del.className = 'btn btn-danger'; + del.style.cssText = 'padding:.2rem .5rem;font-size:.75rem'; + del.textContent = '✕'; + del.addEventListener('click', () => deleteEntry(e.key)); + li.append(key, sz, del); + list.appendChild(li); + }); + + summary.textContent = + `${entries.length} file${entries.length !== 1 ? 's' : ''} — ${fmt_bytes(total)}`; + } catch (e) { + console.error('Failed to fetch /api/storage', e); + } +} + +async function deleteEntry(key) { + await api('DELETE', `/api/storage?key=${encodeURIComponent(key)}`); + refreshStorage(); +} + +async function clearCache() { + if (!confirm('Delete all cached files?')) return; + await api('DELETE', '/api/storage'); + refreshStorage(); +} + +/* ── playback ─────────────────────────────────────────────────────────── */ + +async function sendPlayback(action, value) { + const body = value !== undefined + ? `action=${action}&value=${encodeURIComponent(value)}` + : `action=${action}`; + await api('POST', '/api/playback', body); + refreshInfo(); +} + +/* ── bluetooth ────────────────────────────────────────────────────────── */ + +async function refreshBt() { + try { + const d = await api('GET', '/api/bluetooth'); + const status = document.getElementById('bt-status'); + const btn = document.getElementById('btn-bt-toggle'); + if (d.enabled) { + status.textContent = 'Enabled'; + status.style.color = '#48bb78'; + btn.textContent = 'Disable'; + } else { + status.textContent = 'Disabled'; + status.style.color = '#a0aec0'; + btn.textContent = 'Enable'; + } + } catch (e) { + console.error('Failed to fetch /api/bluetooth', e); + } +} + +async function toggleBt() { + const enabled = document.getElementById('bt-status').textContent === 'Enabled'; + await api('POST', '/api/bluetooth', `enabled=${!enabled}`); + refreshBt(); +} + +/* ── init ─────────────────────────────────────────────────────────────── */ + +document.addEventListener('DOMContentLoaded', () => { + refreshInfo(); + refreshStorage(); + refreshBt(); + + document.getElementById('btn-refresh-info').addEventListener('click', refreshInfo); + document.getElementById('btn-clear-cache').addEventListener('click', clearCache); + document.getElementById('btn-bt-toggle').addEventListener('click', toggleBt); + document.getElementById('btn-pause').addEventListener('click', () => sendPlayback('pause')); + document.getElementById('btn-stop').addEventListener('click', () => sendPlayback('stop')); + + const slider = document.getElementById('volume-slider'); + const label = document.getElementById('volume-label'); + slider.addEventListener('input', () => { label.textContent = slider.value; }); + slider.addEventListener('change', () => sendPlayback('volume', slider.value)); + + /* Auto-refresh info every 5 s while page is visible */ + setInterval(() => { if (!document.hidden) refreshInfo(); }, 5000); +}); diff --git a/www/styles.css b/www/styles.css index e69de29..f77ecd8 100644 --- a/www/styles.css +++ b/www/styles.css @@ -0,0 +1,125 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: #f0f4f8; + color: #1a202c; + min-height: 100vh; +} + +header { + background: #2d3748; + color: #fff; + padding: 1rem 1.5rem; +} + +header h1 { + font-size: 1.5rem; + font-weight: 700; +} + +main { + max-width: 700px; + margin: 1.5rem auto; + padding: 0 1rem; + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.card { + background: #fff; + border-radius: 10px; + box-shadow: 0 2px 8px rgba(0,0,0,.08); + padding: 1.25rem 1.5rem; +} + +.card h2 { + font-size: 1rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .05em; + color: #718096; + margin-bottom: 1rem; +} + +/* Playback */ +.track-uri { + font-size: .9rem; + color: #4a5568; + word-break: break-all; + min-height: 1.2em; + margin-bottom: .75rem; +} + +.playback-controls { + display: flex; + gap: .5rem; + margin-bottom: 1rem; +} + +.volume-row { + display: flex; + align-items: center; + gap: .75rem; +} + +.volume-row label { font-size: .875rem; color: #718096; white-space: nowrap; } +.volume-row input { flex: 1; } +#volume-label { font-size: .875rem; width: 2rem; text-align: right; } + +/* Buttons */ +.btn { + padding: .45rem .9rem; + border: none; + border-radius: 6px; + font-size: .875rem; + font-weight: 600; + cursor: pointer; + transition: opacity .15s; +} + +.btn:disabled { opacity: .45; cursor: not-allowed; } +.btn:not(:disabled):hover { opacity: .85; } + +.btn-primary { background: #4299e1; color: #fff; } +.btn-secondary { background: #e2e8f0; color: #2d3748; } +.btn-danger { background: #fc8181; color: #fff; } + +/* Device info */ +.info-table { width: 100%; border-collapse: collapse; margin-bottom: .75rem; } +.info-table th, .info-table td { padding: .35rem .5rem; text-align: left; font-size: .875rem; } +.info-table th { color: #718096; width: 40%; } +.info-table tr:not(:last-child) th, +.info-table tr:not(:last-child) td { border-bottom: 1px solid #edf2f7; } + +/* Storage */ +.storage-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: .75rem; + font-size: .875rem; + color: #4a5568; +} + +.cache-list { list-style: none; } + +.cache-list li { + display: flex; + align-items: center; + justify-content: space-between; + padding: .4rem 0; + border-bottom: 1px solid #edf2f7; + font-size: .875rem; +} + +.cache-list li:last-child { border-bottom: none; } + +.cache-list .cache-key { font-family: monospace; color: #4a5568; } +.cache-list .cache-size { color: #a0aec0; } + +/* Bluetooth */ +.bt-status { font-weight: 600; margin-bottom: .5rem; } +#btn-bt-toggle { margin-bottom: .5rem; } +.note { font-size: .8rem; color: #a0aec0; }