Player: seek and volume on /api/player

seek {ms} runs on the Spotify session task like a seek from the app (buffer
flushed, app notified). volume {value}|{delta} sets the player volume and
pushes it to the session, so the app's slider follows; setRemoteVolume now
also runs on the session task.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 23:04:11 +10:00
parent 8485ecb5b7
commit a55a3ce14a
4 changed files with 94 additions and 18 deletions
+1 -1
View File
@@ -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. - 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. - [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. - 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. - [ ] 8. Mono sum, gain, polish.
## Phase 2 (parked) ## Phase 2 (parked)
+5 -2
View File
@@ -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. // the client credentials are set; otherwise not advertised and a running session is ended.
// A new device name ends the session and re-advertises. // A new device name ends the session and re-advertises.
void spotify_apply(void); 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 // 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. // 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. // 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. // Resume: back to what the config says. Used around firmware uploads.
void spotify_suspend(bool suspend); void spotify_suspend(bool suspend);
+28 -9
View File
@@ -57,7 +57,12 @@ static std::string s_last_id; // track of the most recent data callbac
static std::atomic<bool> s_depleted{false}; static std::atomic<bool> s_depleted{false};
static std::mutex s_cmd_mutex; static std::mutex s_cmd_mutex;
static std::deque<spotify_cmd_t> 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<Command> s_cmds; // from spotify_command(), run by the session loop
static uint32_t s_cmd_queued; // commands queued so far (s_cmd_mutex) static uint32_t s_cmd_queued; // commands queued so far (s_cmd_mutex)
static std::atomic<uint32_t> s_cmd_done{0}; // commands run so far static std::atomic<uint32_t> s_cmd_done{0}; // commands run so far
@@ -123,13 +128,14 @@ extern "C" void spotify_set_volume(int pct)
return; return;
} }
s_volume = v; s_volume = v;
auto h = s_handler; if (s_handler) {
if (h) { std::lock_guard<std::mutex> lock(s_cmd_mutex);
h->setRemoteVolume(v); 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) { if (!s_handler) {
return false; return false;
@@ -137,7 +143,7 @@ extern "C" bool spotify_command(spotify_cmd_t cmd)
uint32_t seq; uint32_t seq;
{ {
std::lock_guard<std::mutex> lock(s_cmd_mutex); std::lock_guard<std::mutex> lock(s_cmd_mutex);
s_cmds.push_back(cmd); s_cmds.push_back({ cmd, arg });
seq = ++s_cmd_queued; seq = ++s_cmd_queued;
} }
// Wait until it has run (the loop runs every <= 200 ms), so callers can report the new state. // 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. // Session loop: run queued commands on the task that also handles Spotify's frames.
static void run_commands(const std::shared_ptr<cspot::SpircHandler> &handler) static void run_commands(const std::shared_ptr<cspot::SpircHandler> &handler)
{ {
std::deque<spotify_cmd_t> cmds; std::deque<Command> cmds;
uint32_t seq; uint32_t seq;
{ {
std::lock_guard<std::mutex> lock(s_cmd_mutex); std::lock_guard<std::mutex> lock(s_cmd_mutex);
cmds.swap(s_cmds); cmds.swap(s_cmds);
seq = s_cmd_queued; seq = s_cmd_queued;
} }
for (auto cmd : cmds) { for (auto [cmd, arg] : cmds) {
switch (cmd) { switch (cmd) {
case SPOTIFY_CMD_PLAY: case SPOTIFY_CMD_PLAY:
case SPOTIFY_CMD_PAUSE: case SPOTIFY_CMD_PAUSE:
@@ -170,8 +176,21 @@ static void run_commands(const std::shared_ptr<cspot::SpircHandler> &handler)
ESP_LOGW(TAG, "no %s track", cmd == SPOTIFY_CMD_NEXT ? "next" : "previous"); ESP_LOGW(TAG, "no %s track", cmd == SPOTIFY_CMD_NEXT ? "next" : "previous");
} }
break; 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; s_cmd_done = seq;
} }
+60 -6
View File
@@ -423,7 +423,7 @@ static const char *transport(const char *cmd)
if (src == PLAYER_SRC_SPOTIFY) { if (src == PLAYER_SRC_SPOTIFY) {
spotify_cmd_t c = pause ? SPOTIFY_CMD_PAUSE : play ? SPOTIFY_CMD_PLAY : spotify_cmd_t c = pause ? SPOTIFY_CMD_PAUSE : play ? SPOTIFY_CMD_PLAY :
next ? SPOTIFY_CMD_NEXT : SPOTIFY_CMD_PREV; 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 (src == PLAYER_SRC_HLS) {
if (next || prev) { if (next || prev) {
@@ -435,6 +435,61 @@ static const char *transport(const char *cmd)
return "no player source active"; 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/<cmd> // POST /api/player/<cmd>
static esp_err_t player_post(httpd_req_t *req) 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++) { for (int i = 0; i < sizeof(TRANSPORT) / sizeof(TRANSPORT[0]); i++) {
if (!strcmp(name, TRANSPORT[i])) { if (!strcmp(name, TRANSPORT[i])) {
const char *err = transport(name); const char *err = transport(name);
if (err) { return err ? send_409(req, err) : send_player(req);
httpd_resp_set_status(req, "409 Conflict");
return httpd_resp_sendstr(req, err);
}
return 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"); return httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "unknown player command");
} }