diff --git a/CLAUDE.md b/CLAUDE.md index 87e69a0..87a64e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,7 +69,7 @@ Repo: https://gitea.apointless.space/bsncubed/aes67-ESP32-P4 - An old, long-idle session can get out of sync with the app: it sends empty Load frames ("No tracks in frame") instead of Pause/Play, so controls do nothing. Quitting and reopening Spotify on the Mac fixes it. - [x] failover (auto mode): no session or (with `failover_on_pause`) paused for `failover_delay_s` -> HLS; Spotify playing -> Spotify within ~1 s; 30 ms fades; HLS suspended while Spotify plays; cspot is held back (not drained) while it isn't Spotify's turn, so it resumes where it paused. Verified: no session -> HLS, play -> Spotify, pause stays (on_pause off), pause -> HLS after 5 s (on_pause on), play -> Spotify at the paused position. - Open: `aes67_tx resync` (TX 20-27 ms late) ~11 s after each switch to Spotify (seen 3 times); clicks at switches not yet checked in a recording; HLS start sometimes hits CDN read timeouts. - - [ ] /api/player: GET and transport (play/pause/toggle/stop/next/prev) done. Next: seek/volume, source override/url. + - [ ] /api/player: GET, transport (play/pause/toggle/stop/next/prev), seek and volume done (the app follows). Next: progress bar (seek) in the web UI, then source override/url. - [ ] 8. Mono sum, gain, polish. ## Phase 2 (parked) diff --git a/components/spotify/include/spotify.h b/components/spotify/include/spotify.h index c8799a6..f2e97e5 100644 --- a/components/spotify/include/spotify.h +++ b/components/spotify/include/spotify.h @@ -32,10 +32,13 @@ esp_err_t spotify_init(spotify_pcm_cb_t pcm, spotify_event_cb_t event, spotify_p // the client credentials are set; otherwise not advertised and a running session is ended. // A new device name ends the session and re-advertises. void spotify_apply(void); -typedef enum { SPOTIFY_CMD_PLAY, SPOTIFY_CMD_PAUSE, SPOTIFY_CMD_NEXT, SPOTIFY_CMD_PREV } spotify_cmd_t; +typedef enum { + SPOTIFY_CMD_PLAY, SPOTIFY_CMD_PAUSE, SPOTIFY_CMD_NEXT, SPOTIFY_CMD_PREV, + SPOTIFY_CMD_SEEK, // arg: position in ms +} spotify_cmd_t; // Control the running session (the Spotify app follows). Runs on the session task; waits until it // has run (max 1 s). Returns false without a session. -bool spotify_command(spotify_cmd_t cmd); +bool spotify_command(spotify_cmd_t cmd, uint32_t arg); // Suspend: end any running session (waits until its tasks are gone, max ~3 s) and refuse new ones. // Resume: back to what the config says. Used around firmware uploads. void spotify_suspend(bool suspend); diff --git a/components/spotify/spotify.cpp b/components/spotify/spotify.cpp index d423b4c..4e44036 100644 --- a/components/spotify/spotify.cpp +++ b/components/spotify/spotify.cpp @@ -57,7 +57,12 @@ static std::string s_last_id; // track of the most recent data callbac static std::atomic s_depleted{false}; static std::mutex s_cmd_mutex; -static std::deque s_cmds; // from spotify_command(), run by the session loop +enum { CMD_VOLUME = 100 }; // internal: push s_volume to the app +struct Command { + int cmd; // spotify_cmd_t or CMD_VOLUME + uint32_t arg; +}; +static std::deque s_cmds; // from spotify_command(), run by the session loop static uint32_t s_cmd_queued; // commands queued so far (s_cmd_mutex) static std::atomic s_cmd_done{0}; // commands run so far @@ -123,13 +128,14 @@ extern "C" void spotify_set_volume(int pct) return; } s_volume = v; - auto h = s_handler; - if (h) { - h->setRemoteVolume(v); + if (s_handler) { + std::lock_guard lock(s_cmd_mutex); + s_cmds.push_back({ CMD_VOLUME, 0 }); + ++s_cmd_queued; } } -extern "C" bool spotify_command(spotify_cmd_t cmd) +extern "C" bool spotify_command(spotify_cmd_t cmd, uint32_t arg) { if (!s_handler) { return false; @@ -137,7 +143,7 @@ extern "C" bool spotify_command(spotify_cmd_t cmd) uint32_t seq; { std::lock_guard lock(s_cmd_mutex); - s_cmds.push_back(cmd); + s_cmds.push_back({ cmd, arg }); seq = ++s_cmd_queued; } // Wait until it has run (the loop runs every <= 200 ms), so callers can report the new state. @@ -150,14 +156,14 @@ extern "C" bool spotify_command(spotify_cmd_t cmd) // Session loop: run queued commands on the task that also handles Spotify's frames. static void run_commands(const std::shared_ptr &handler) { - std::deque cmds; + std::deque cmds; uint32_t seq; { std::lock_guard lock(s_cmd_mutex); cmds.swap(s_cmds); seq = s_cmd_queued; } - for (auto cmd : cmds) { + for (auto [cmd, arg] : cmds) { switch (cmd) { case SPOTIFY_CMD_PLAY: case SPOTIFY_CMD_PAUSE: @@ -170,8 +176,21 @@ static void run_commands(const std::shared_ptr &handler) ESP_LOGW(TAG, "no %s track", cmd == SPOTIFY_CMD_NEXT ? "next" : "previous"); } break; + case SPOTIFY_CMD_SEEK: + // As for a seek from the app (SpircHandler::handleFrame): the buffered audio is void. + handler->getTrackPlayer()->seekMs(arg ? arg : 1); // 0 means "no seek" to TrackPlayer + handler->updatePositionMs(arg); // notifies the app + clear_boundaries(false); + s_depleted = false; + if (s_event_cb) { + s_event_cb(SPOTIFY_EV_FLUSH, 0); + } + break; + case CMD_VOLUME: + handler->setRemoteVolume(s_volume); // notifies the app + break; } - ESP_LOGI(TAG, "command %d", (int)cmd); + ESP_LOGI(TAG, "command %d %lu", cmd, (unsigned long)arg); } s_cmd_done = seq; } diff --git a/main/player.c b/main/player.c index 5f37a9f..15369be 100644 --- a/main/player.c +++ b/main/player.c @@ -423,7 +423,7 @@ static const char *transport(const char *cmd) if (src == PLAYER_SRC_SPOTIFY) { spotify_cmd_t c = pause ? SPOTIFY_CMD_PAUSE : play ? SPOTIFY_CMD_PLAY : next ? SPOTIFY_CMD_NEXT : SPOTIFY_CMD_PREV; - return spotify_command(c) ? NULL : "no Spotify session"; + return spotify_command(c, 0) ? NULL : "no Spotify session"; } if (src == PLAYER_SRC_HLS) { if (next || prev) { @@ -435,6 +435,61 @@ static const char *transport(const char *cmd) return "no player source active"; } +// Small JSON body of a POST; NULL if missing or invalid. +static cJSON *read_body(httpd_req_t *req) +{ + char buf[128]; + if (req->content_len == 0 || req->content_len >= sizeof(buf)) { + return NULL; + } + size_t got = 0; + while (got < req->content_len) { + int r = httpd_req_recv(req, buf + got, req->content_len - got); + if (r == HTTPD_SOCK_ERR_TIMEOUT) { + continue; + } + if (r <= 0) { + return NULL; + } + got += r; + } + buf[got] = '\0'; + return cJSON_Parse(buf); +} + +static esp_err_t send_409(httpd_req_t *req, const char *msg) +{ + httpd_resp_set_status(req, "409 Conflict"); + return httpd_resp_sendstr(req, msg); +} + +// POST /api/player/seek {ms}, /api/player/volume {value} or {delta} +static esp_err_t seek_or_volume(httpd_req_t *req, bool seek) +{ + cJSON *body = read_body(req); + const cJSON *v = cJSON_GetObjectItemCaseSensitive(body, seek ? "ms" : "value"); + const cJSON *d = seek ? NULL : cJSON_GetObjectItemCaseSensitive(body, "delta"); + double val = cJSON_IsNumber(v) ? v->valuedouble : cJSON_IsNumber(d) ? s_volume_pct + d->valuedouble : -1; + cJSON_Delete(body); + if (val < 0 && !(cJSON_IsNumber(d) && !seek)) { + return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, seek ? "expected {\"ms\": n}" : + "expected {\"value\": 0-100} or {\"delta\": n}"); + } + if (seek) { + if (s_src != PLAYER_SRC_SPOTIFY) { + return send_409(req, "source can't seek"); + } + if (!spotify_command(SPOTIFY_CMD_SEEK, (uint32_t)val)) { + return send_409(req, "no Spotify session"); + } + } else { + int pct = val < 0 ? 0 : val > 100 ? 100 : (int)(val + 0.5); + set_volume(pct); + spotify_set_volume(pct); // the app follows (if a session runs) + } + return send_player(req); +} + // POST /api/player/ static esp_err_t player_post(httpd_req_t *req) { @@ -450,13 +505,12 @@ static esp_err_t player_post(httpd_req_t *req) for (int i = 0; i < sizeof(TRANSPORT) / sizeof(TRANSPORT[0]); i++) { if (!strcmp(name, TRANSPORT[i])) { const char *err = transport(name); - if (err) { - httpd_resp_set_status(req, "409 Conflict"); - return httpd_resp_sendstr(req, err); - } - return send_player(req); + return err ? send_409(req, err) : send_player(req); } } + if (!strcmp(name, "seek") || !strcmp(name, "volume")) { + return seek_or_volume(req, name[0] == 's'); + } return httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "unknown player command"); }