Player: transport commands on /api/player
play/pause/toggle/stop/next/prev. Spotify commands run on the session task (same thread as cspot's frame handling) and the POST waits until they ran, so it answers the new state. HLS pause stops fetching and resume rejoins at the live edge; next/prev on HLS and anything on tone/off return 409. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 done (source, state, track, position as the app shows it, volume, can). Next: transport POSTs, seek/volume, source override/url.
|
- [ ] /api/player: GET and transport (play/pause/toggle/stop/next/prev) done. Next: seek/volume, source override/url.
|
||||||
- [ ] 8. Mono sum, gain, polish.
|
- [ ] 8. Mono sum, gain, polish.
|
||||||
|
|
||||||
## Phase 2 (parked)
|
## Phase 2 (parked)
|
||||||
|
|||||||
@@ -32,8 +32,10 @@ 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);
|
||||||
// Pause/resume the running session (the Spotify app follows). No-op without a session.
|
typedef enum { SPOTIFY_CMD_PLAY, SPOTIFY_CMD_PAUSE, SPOTIFY_CMD_NEXT, SPOTIFY_CMD_PREV } spotify_cmd_t;
|
||||||
void spotify_pause(bool pause);
|
// 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);
|
||||||
// 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);
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ static std::deque<Boundary> s_bounds;
|
|||||||
static std::string s_last_id; // track of the most recent data callback
|
static std::string s_last_id; // track of the most recent data callback
|
||||||
static std::atomic<bool> s_depleted{false};
|
static std::atomic<bool> s_depleted{false};
|
||||||
|
|
||||||
|
static std::mutex s_cmd_mutex;
|
||||||
|
static std::deque<spotify_cmd_t> 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<uint32_t> s_cmd_done{0}; // commands run so far
|
||||||
|
|
||||||
static std::mutex s_track_mutex;
|
static std::mutex s_track_mutex;
|
||||||
static cspot::TrackInfo s_track; // audible track (TRACK_INFO), guarded by s_track_mutex
|
static cspot::TrackInfo s_track; // audible track (TRACK_INFO), guarded by s_track_mutex
|
||||||
|
|
||||||
@@ -124,13 +129,51 @@ extern "C" void spotify_set_volume(int pct)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extern "C" void spotify_pause(bool pause)
|
extern "C" bool spotify_command(spotify_cmd_t cmd)
|
||||||
{
|
{
|
||||||
auto h = s_handler;
|
if (!s_handler) {
|
||||||
if (h) {
|
return false;
|
||||||
h->setPause(pause);
|
|
||||||
ESP_LOGI(TAG, "%s", pause ? "paused" : "resumed");
|
|
||||||
}
|
}
|
||||||
|
uint32_t seq;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(s_cmd_mutex);
|
||||||
|
s_cmds.push_back(cmd);
|
||||||
|
seq = ++s_cmd_queued;
|
||||||
|
}
|
||||||
|
// Wait until it has run (the loop runs every <= 200 ms), so callers can report the new state.
|
||||||
|
for (int i = 0; i < 50 && (int32_t)(s_cmd_done - seq) < 0 && s_handler; i++) {
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(20));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
{
|
||||||
|
std::deque<spotify_cmd_t> cmds;
|
||||||
|
uint32_t seq;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(s_cmd_mutex);
|
||||||
|
cmds.swap(s_cmds);
|
||||||
|
seq = s_cmd_queued;
|
||||||
|
}
|
||||||
|
for (auto cmd : cmds) {
|
||||||
|
switch (cmd) {
|
||||||
|
case SPOTIFY_CMD_PLAY:
|
||||||
|
case SPOTIFY_CMD_PAUSE:
|
||||||
|
handler->setPause(cmd == SPOTIFY_CMD_PAUSE); // notifies the app, sends PLAY_PAUSE
|
||||||
|
break;
|
||||||
|
case SPOTIFY_CMD_NEXT:
|
||||||
|
case SPOTIFY_CMD_PREV:
|
||||||
|
// The new track's load notifies the app and flushes our buffer (PLAYBACK_START).
|
||||||
|
if (!(cmd == SPOTIFY_CMD_NEXT ? handler->nextSong() : handler->previousSong())) {
|
||||||
|
ESP_LOGW(TAG, "no %s track", cmd == SPOTIFY_CMD_NEXT ? "next" : "previous");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
ESP_LOGI(TAG, "command %d", (int)cmd);
|
||||||
|
}
|
||||||
|
s_cmd_done = seq;
|
||||||
}
|
}
|
||||||
|
|
||||||
extern "C" bool spotify_session_active(void)
|
extern "C" bool spotify_session_active(void)
|
||||||
@@ -394,8 +437,14 @@ static void session(void)
|
|||||||
ESP_LOGI(TAG, "connected as %s", ctx->config.username.c_str());
|
ESP_LOGI(TAG, "connected as %s", ctx->config.username.c_str());
|
||||||
clear_boundaries(true);
|
clear_boundaries(true);
|
||||||
s_depleted = false;
|
s_depleted = false;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(s_cmd_mutex);
|
||||||
|
s_cmds.clear();
|
||||||
|
s_cmd_done = s_cmd_queued;
|
||||||
|
}
|
||||||
while (!s_stop) {
|
while (!s_stop) {
|
||||||
ctx->session->handlePacket(); // waits at most 200 ms
|
ctx->session->handlePacket(); // waits at most 200 ms
|
||||||
|
run_commands(handler);
|
||||||
check_playback(handler);
|
check_playback(handler);
|
||||||
}
|
}
|
||||||
// Stops the queue and player tasks (waits for them), then closes the connection.
|
// Stops the queue and player tasks (waits for them), then closes the connection.
|
||||||
|
|||||||
@@ -42,6 +42,6 @@ source (cspot Spotify Connect | HLS player) -> decode (Vorbis/AAC/MP3) -> SRC 44
|
|||||||
- POST /api/player/seek {ms}; POST /api/player/volume {value} or {delta}
|
- POST /api/player/seek {ms}; POST /api/player/volume {value} or {delta}
|
||||||
- POST /api/player/source {source: spotify|hls|off|config}: runtime override, not saved; "config" returns to the configured mode
|
- POST /api/player/source {source: spotify|hls|off|config}: runtime override, not saved; "config" returns to the configured mode
|
||||||
- POST /api/player/url {url}: play an m3u8 now (runtime, not saved)
|
- POST /api/player/url {url}: play an m3u8 now (runtime, not saved)
|
||||||
- On Spotify, commands go to cspot (the Spotify app stays in sync, including volume). On HLS (live): pause = stop fetching, resume = rejoin at live edge; next/prev/seek return 409 and `can` reflects that.
|
- On Spotify, commands go to cspot (the Spotify app stays in sync, including volume). stop = pause (the session stays). prev restarts the track after its first 3 s (cspot, like the apps). After next/prev the response can still show the old track; the new one follows within ~1 s. On HLS (live): pause/stop = stop fetching, resume = rejoin at live edge (a pause lasts until the next source switch); next/prev/seek return 409 and `can` reflects that. Tone/off, or Spotify without a session: transport returns 409.
|
||||||
- Volume = runtime player volume; source.gain_db is a fixed trim on top.
|
- Volume = runtime player volume; source.gain_db is a fixed trim on top.
|
||||||
- No auth yet: LAN only.
|
- No auth yet: LAN only.
|
||||||
|
|||||||
@@ -15,4 +15,5 @@ esp_err_t hls_start(hls_sink_t sink);
|
|||||||
// fetching runs only when no reason holds it.
|
// fetching runs only when no reason holds it.
|
||||||
#define HLS_SUSPEND_OTA (1 << 0)
|
#define HLS_SUSPEND_OTA (1 << 0)
|
||||||
#define HLS_SUSPEND_FAILOVER (1 << 1)
|
#define HLS_SUSPEND_FAILOVER (1 << 1)
|
||||||
|
#define HLS_SUSPEND_USER (1 << 2) // paused via the player API
|
||||||
void hls_suspend(unsigned reason, bool suspend);
|
void hls_suspend(unsigned reason, bool suspend);
|
||||||
|
|||||||
+85
-7
@@ -37,6 +37,7 @@ static volatile bool s_playing; // ring output running (after prefill)
|
|||||||
static volatile bool s_flush; // consumer drops buffered audio on the next read
|
static volatile bool s_flush; // consumer drops buffered audio on the next read
|
||||||
static volatile bool s_paused; // Spotify paused: silence, buffer kept, no underrun
|
static volatile bool s_paused; // Spotify paused: silence, buffer kept, no underrun
|
||||||
static volatile bool s_sp_playing; // Spotify has started playback and is not paused
|
static volatile bool s_sp_playing; // Spotify has started playback and is not paused
|
||||||
|
static volatile bool s_hls_paused; // HLS paused via the API: not fetching, silence
|
||||||
static audio_conv_t *s_spotify_conv;
|
static audio_conv_t *s_spotify_conv;
|
||||||
|
|
||||||
// Failover (mode auto)
|
// Failover (mode auto)
|
||||||
@@ -107,7 +108,8 @@ static size_t read_current(int32_t *buf, size_t frames)
|
|||||||
s_flush = false;
|
s_flush = false;
|
||||||
s_playing = false;
|
s_playing = false;
|
||||||
}
|
}
|
||||||
if (s_src == PLAYER_SRC_OFF || (s_src == PLAYER_SRC_SPOTIFY && s_paused)) {
|
if (s_src == PLAYER_SRC_OFF || (s_src == PLAYER_SRC_SPOTIFY && s_paused) ||
|
||||||
|
(s_src == PLAYER_SRC_HLS && s_hls_paused)) {
|
||||||
memset(buf, 0, frames * CHANNELS * sizeof(int32_t));
|
memset(buf, 0, frames * CHANNELS * sizeof(int32_t));
|
||||||
return frames;
|
return frames;
|
||||||
}
|
}
|
||||||
@@ -158,6 +160,10 @@ static void select_source(player_src_t src)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ESP_LOGI(TAG, "source: %s -> %s", SRC_NAME[s_src], SRC_NAME[src]);
|
ESP_LOGI(TAG, "source: %s -> %s", SRC_NAME[s_src], SRC_NAME[src]);
|
||||||
|
if (s_hls_paused) { // an API pause of HLS lasts until the next switch
|
||||||
|
s_hls_paused = false;
|
||||||
|
hls_suspend(HLS_SUSPEND_USER, false);
|
||||||
|
}
|
||||||
if (src == PLAYER_SRC_TONE || s_src == PLAYER_SRC_TONE) {
|
if (src == PLAYER_SRC_TONE || s_src == PLAYER_SRC_TONE) {
|
||||||
s_target = s_src = src;
|
s_target = s_src = src;
|
||||||
s_flush = true;
|
s_flush = true;
|
||||||
@@ -198,6 +204,9 @@ size_t player_write(player_src_t src, const int32_t *frames, size_t n)
|
|||||||
{
|
{
|
||||||
size_t done = 0;
|
size_t done = 0;
|
||||||
while (done < n) {
|
while (done < n) {
|
||||||
|
if (src == PLAYER_SRC_HLS && s_hls_paused) {
|
||||||
|
return n; // paused: let the decoder finish the segment it is on
|
||||||
|
}
|
||||||
if (s_src != src) {
|
if (s_src != src) {
|
||||||
if (s_target != src) {
|
if (s_target != src) {
|
||||||
return n; // not this source's turn: drop
|
return n; // not this source's turn: drop
|
||||||
@@ -327,7 +336,8 @@ static const char *source_state(player_src_t src)
|
|||||||
{
|
{
|
||||||
return src == PLAYER_SRC_TONE ? "playing" : src == PLAYER_SRC_OFF ? "idle" :
|
return src == PLAYER_SRC_TONE ? "playing" : src == PLAYER_SRC_OFF ? "idle" :
|
||||||
src == PLAYER_SRC_SPOTIFY && !spotify_session_active() ? "idle" :
|
src == PLAYER_SRC_SPOTIFY && !spotify_session_active() ? "idle" :
|
||||||
src == PLAYER_SRC_SPOTIFY && s_paused ? "paused" : s_playing ? "playing" : "buffering";
|
src == PLAYER_SRC_SPOTIFY && s_paused ? "paused" :
|
||||||
|
src == PLAYER_SRC_HLS && s_hls_paused ? "paused" : s_playing ? "playing" : "buffering";
|
||||||
}
|
}
|
||||||
|
|
||||||
static void player_status(cJSON *st)
|
static void player_status(cJSON *st)
|
||||||
@@ -373,7 +383,7 @@ static esp_err_t send_player(httpd_req_t *req)
|
|||||||
}
|
}
|
||||||
cJSON_AddNumberToObject(o, "volume", s_volume_pct);
|
cJSON_AddNumberToObject(o, "volume", s_volume_pct);
|
||||||
cJSON *can = cJSON_AddObjectToObject(o, "can");
|
cJSON *can = cJSON_AddObjectToObject(o, "can");
|
||||||
cJSON_AddBoolToObject(can, "pause", sp);
|
cJSON_AddBoolToObject(can, "pause", sp || src == PLAYER_SRC_HLS);
|
||||||
cJSON_AddBoolToObject(can, "next", sp);
|
cJSON_AddBoolToObject(can, "next", sp);
|
||||||
cJSON_AddBoolToObject(can, "prev", sp);
|
cJSON_AddBoolToObject(can, "prev", sp);
|
||||||
cJSON_AddBoolToObject(can, "seek", sp);
|
cJSON_AddBoolToObject(can, "seek", sp);
|
||||||
@@ -387,6 +397,69 @@ static esp_err_t player_get(httpd_req_t *req)
|
|||||||
return send_player(req);
|
return send_player(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void hls_pause(bool pause)
|
||||||
|
{
|
||||||
|
if (pause == s_hls_paused) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
s_hls_paused = pause;
|
||||||
|
hls_suspend(HLS_SUSPEND_USER, pause);
|
||||||
|
s_flush = true; // live: resume rejoins at the live edge, never plays the old buffer
|
||||||
|
ESP_LOGI(TAG, "hls %s", pause ? "paused" : "resumed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transport on the active source. Returns NULL, or the reason it can't be done (409).
|
||||||
|
static const char *transport(const char *cmd)
|
||||||
|
{
|
||||||
|
player_src_t src = s_src;
|
||||||
|
bool pause = !strcmp(cmd, "pause") || !strcmp(cmd, "stop");
|
||||||
|
bool play = !strcmp(cmd, "play");
|
||||||
|
bool next = !strcmp(cmd, "next"), prev = !strcmp(cmd, "prev");
|
||||||
|
if (!strcmp(cmd, "toggle")) {
|
||||||
|
bool paused = src == PLAYER_SRC_SPOTIFY ? s_paused : s_hls_paused;
|
||||||
|
pause = !paused;
|
||||||
|
play = paused;
|
||||||
|
}
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
if (src == PLAYER_SRC_HLS) {
|
||||||
|
if (next || prev) {
|
||||||
|
return "live stream: no next/previous";
|
||||||
|
}
|
||||||
|
hls_pause(pause);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
return "no player source active";
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/player/<cmd>
|
||||||
|
static esp_err_t player_post(httpd_req_t *req)
|
||||||
|
{
|
||||||
|
static const char *const TRANSPORT[] = { "play", "pause", "toggle", "stop", "next", "prev" };
|
||||||
|
const char *cmd = req->uri + strlen("/api/player/");
|
||||||
|
char name[16];
|
||||||
|
size_t len = strcspn(cmd, "?");
|
||||||
|
if (len >= sizeof(name)) {
|
||||||
|
return httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "unknown player command");
|
||||||
|
}
|
||||||
|
memcpy(name, cmd, len);
|
||||||
|
name[len] = '\0';
|
||||||
|
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 httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "unknown player command");
|
||||||
|
}
|
||||||
|
|
||||||
esp_err_t player_init(void)
|
esp_err_t player_init(void)
|
||||||
{
|
{
|
||||||
esp_err_t err = audio_ring_init(RING_FRAMES, CHANNELS);
|
esp_err_t err = audio_ring_init(RING_FRAMES, CHANNELS);
|
||||||
@@ -416,10 +489,15 @@ esp_err_t player_init(void)
|
|||||||
if (xTaskCreate(control_task, "player_ctl", 3072, NULL, 4, NULL) != pdPASS) {
|
if (xTaskCreate(control_task, "player_ctl", 3072, NULL, 4, NULL) != pdPASS) {
|
||||||
return ESP_ERR_NO_MEM;
|
return ESP_ERR_NO_MEM;
|
||||||
}
|
}
|
||||||
static const httpd_uri_t get = { .uri = "/api/player", .method = HTTP_GET, .handler = player_get };
|
static const httpd_uri_t uris[] = {
|
||||||
err = web_register_uri(&get);
|
{ .uri = "/api/player", .method = HTTP_GET, .handler = player_get },
|
||||||
if (err != ESP_OK) {
|
{ .uri = "/api/player/*", .method = HTTP_POST, .handler = player_post },
|
||||||
return err;
|
};
|
||||||
|
for (int i = 0; i < sizeof(uris) / sizeof(uris[0]); i++) {
|
||||||
|
err = web_register_uri(&uris[i]);
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
return err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return status_register(player_status);
|
return status_register(player_status);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user