Player: runtime source override and play-a-URL on /api/player

The player now owns the effective mode (config or override) and passes it
down: spotify_apply(enable) and hls_set_url(url) instead of both reading
source.mode from the config. /api/player/source forces spotify|hls|off
(config returns to the saved mode), /api/player/url plays an m3u8 now;
neither is saved. GET reports it in `forced`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 23:15:38 +10:00
parent fd88e02e96
commit e5c3c0ad8a
6 changed files with 105 additions and 31 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, transport (play/pause/toggle/stop/next/prev), seek and volume done (the app follows). Web UI progress bar (seek on release) done. Next: source override/url. - [x] /api/player: GET, transport, seek, volume (the app follows), source override and url (runtime, not saved); web UI progress bar.
- [ ] 8. Mono sum, gain, polish. - [ ] 8. Mono sum, gain, polish.
## Phase 2 (parked) ## Phase 2 (parked)
+6 -5
View File
@@ -25,13 +25,14 @@ typedef void (*spotify_event_cb_t)(spotify_event_t ev, int value);
// played out so far. Used to tell Spotify when a new track actually becomes audible. // played out so far. Used to tell Spotify when a new track actually becomes audible.
typedef uint32_t (*spotify_pos_cb_t)(void); typedef uint32_t (*spotify_pos_cb_t)(void);
// Registers the zeroconf endpoints (/spotify_info), starts the session task and applies the config. // Registers the zeroconf endpoints (/spotify_info) and starts the session task (disabled until
// spotify_apply(true)).
esp_err_t spotify_init(spotify_pcm_cb_t pcm, spotify_event_cb_t event, spotify_pos_cb_t written, esp_err_t spotify_init(spotify_pcm_cb_t pcm, spotify_event_cb_t event, spotify_pos_cb_t written,
spotify_pos_cb_t played); spotify_pos_cb_t played);
// Re-read the "source" config: enabled (advertised, sessions accepted) when mode is spotify/auto and // Enable (advertised, sessions accepted) when the player's mode needs Spotify and the client
// the client credentials are set; otherwise not advertised and a running session is ended. // credentials are set; otherwise not advertised and a running session is ended. Re-reads name and
// A new device name ends the session and re-advertises. // credentials from the "source" config: a new device name ends the session and re-advertises.
void spotify_apply(void); void spotify_apply(bool enable);
typedef enum { typedef enum {
SPOTIFY_CMD_PLAY, SPOTIFY_CMD_PAUSE, SPOTIFY_CMD_NEXT, SPOTIFY_CMD_PREV, SPOTIFY_CMD_PLAY, SPOTIFY_CMD_PAUSE, SPOTIFY_CMD_NEXT, SPOTIFY_CMD_PREV,
SPOTIFY_CMD_SEEK, // arg: position in ms SPOTIFY_CMD_SEEK, // arg: position in ms
+7 -7
View File
@@ -33,6 +33,7 @@ static std::shared_ptr<cspot::LoginBlob> s_blob; // guarded by s_mutex
static std::mutex s_mutex; static std::mutex s_mutex;
static std::string s_name; // advertised device name ("" = not advertised) static std::string s_name; // advertised device name ("" = not advertised)
static std::atomic<bool> s_enabled{false}; static std::atomic<bool> s_enabled{false};
static std::atomic<bool> s_want{false}; // last spotify_apply(): the player needs Spotify
static std::atomic<bool> s_stop{false}; // end the running session static std::atomic<bool> s_stop{false}; // end the running session
static std::atomic<bool> s_got_blob{false}; static std::atomic<bool> s_got_blob{false};
static std::atomic<bool> s_in_session{false}; // session() running (its tasks may exist) static std::atomic<bool> s_in_session{false}; // session() running (its tasks may exist)
@@ -107,7 +108,7 @@ static void check_playback(const std::shared_ptr<cspot::SpircHandler> &handler)
extern "C" void spotify_suspend(bool suspend) extern "C" void spotify_suspend(bool suspend)
{ {
if (!suspend) { if (!suspend) {
spotify_apply(); spotify_apply(s_want);
return; return;
} }
s_enabled = false; s_enabled = false;
@@ -500,15 +501,15 @@ static void spotify_task(void *arg)
} }
} }
extern "C" void spotify_apply(void) extern "C" void spotify_apply(bool enable)
{ {
s_want = enable;
cJSON *src = cfg_get("source"); cJSON *src = cfg_get("source");
std::string mode = cJSON_GetObjectItemCaseSensitive(src, "mode")->valuestring;
std::string name = cJSON_GetObjectItemCaseSensitive(src, "spotify_name")->valuestring; std::string name = cJSON_GetObjectItemCaseSensitive(src, "spotify_name")->valuestring;
bool creds = cJSON_GetObjectItemCaseSensitive(src, "spotify_client_id")->valuestring[0] && bool creds = cJSON_GetObjectItemCaseSensitive(src, "spotify_client_id")->valuestring[0] &&
cJSON_GetObjectItemCaseSensitive(src, "spotify_client_secret")->valuestring[0]; cJSON_GetObjectItemCaseSensitive(src, "spotify_client_secret")->valuestring[0];
cJSON_Delete(src); cJSON_Delete(src);
bool want = (mode == "spotify" || mode == "auto") && creds; bool want = enable && creds;
if (!want || name != s_name) { if (!want || name != s_name) {
// Disable, or re-advertise under a new name: end any session, drop the old entry. // Disable, or re-advertise under a new name: end any session, drop the old entry.
@@ -523,7 +524,7 @@ extern "C" void spotify_apply(void)
} }
if (!want) { if (!want) {
s_state = !creds ? "no client credentials" : "disabled (source mode)"; s_state = !creds ? "no client credentials" : "disabled (source mode)";
if (!creds && (mode == "spotify" || mode == "auto")) { if (!creds && enable) {
ESP_LOGW(TAG, "not started: set the Spotify client ID and secret (web UI, Source)"); ESP_LOGW(TAG, "not started: set the Spotify client ID and secret (web UI, Source)");
} }
return; return;
@@ -558,6 +559,5 @@ extern "C" esp_err_t spotify_init(spotify_pcm_cb_t pcm, spotify_event_cb_t event
if (xTaskCreate(spotify_task, "spotify", 32 * 1024, nullptr, 5, nullptr) != pdPASS) { if (xTaskCreate(spotify_task, "spotify", 32 * 1024, nullptr, 5, nullptr) != pdPASS) {
return ESP_ERR_NO_MEM; return ESP_ERR_NO_MEM;
} }
spotify_apply(); return ESP_OK; // the player enables it (spotify_apply)
return ESP_OK;
} }
+14 -10
View File
@@ -4,7 +4,6 @@
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include "aes67_cfg.h"
#include "esp_crt_bundle.h" #include "esp_crt_bundle.h"
#include "esp_heap_caps.h" #include "esp_heap_caps.h"
#include "esp_http_client.h" #include "esp_http_client.h"
@@ -13,7 +12,7 @@
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#include "freertos/task.h" #include "freertos/task.h"
#define URL_MAX 384 #define URL_MAX HLS_URL_MAX
#define PLAYLIST_MAX (64 * 1024) #define PLAYLIST_MAX (64 * 1024)
#define MAX_SEGMENTS 64 #define MAX_SEGMENTS 64
#define LIVE_BACK 3 // start this many segments behind the live edge #define LIVE_BACK 3 // start this many segments behind the live edge
@@ -33,7 +32,9 @@ typedef struct {
static hls_sink_t s_sink; static hls_sink_t s_sink;
static char *s_text; // playlist buffer (PSRAM) static char *s_text; // playlist buffer (PSRAM)
static media_pl_t *s_pl; // parsed media playlist (PSRAM) static media_pl_t *s_pl; // parsed media playlist (PSRAM)
static char s_url[URL_MAX]; // configured URL static char s_url[URL_MAX]; // URL being played
static char s_want_url[URL_MAX]; // from hls_set_url(), guarded by s_url_lock
static portMUX_TYPE s_url_lock = portMUX_INITIALIZER_UNLOCKED;
static char s_media_url[URL_MAX]; static char s_media_url[URL_MAX];
static uint8_t *s_seg; // current segment (PSRAM) static uint8_t *s_seg; // current segment (PSRAM)
static volatile unsigned s_suspended; // HLS_SUSPEND_* bits static volatile unsigned s_suspended; // HLS_SUSPEND_* bits
@@ -47,15 +48,18 @@ void hls_suspend(unsigned reason, bool suspend)
} }
} }
// hls_url when source.mode needs HLS, else "". void hls_set_url(const char *url)
{
taskENTER_CRITICAL(&s_url_lock);
strlcpy(s_want_url, url, sizeof(s_want_url));
taskEXIT_CRITICAL(&s_url_lock);
}
static void wanted_url(char *out, size_t n) static void wanted_url(char *out, size_t n)
{ {
cJSON *src = cfg_get("source"); taskENTER_CRITICAL(&s_url_lock);
const char *mode = cJSON_GetObjectItemCaseSensitive(src, "mode")->valuestring; strlcpy(out, s_want_url, n);
const char *url = cJSON_GetObjectItemCaseSensitive(src, "hls_url")->valuestring; taskEXIT_CRITICAL(&s_url_lock);
bool on = !strcmp(mode, "hls") || !strcmp(mode, "auto");
strlcpy(out, on ? url : "", n);
cJSON_Delete(src);
} }
static bool still_wanted(void) static bool still_wanted(void)
+4
View File
@@ -10,7 +10,11 @@
// Receives segment data in chunks (MPEG-TS etc.). Return false to abort the segment. // Receives segment data in chunks (MPEG-TS etc.). Return false to abort the segment.
typedef bool (*hls_sink_t)(const uint8_t *data, size_t len, bool segment_start); typedef bool (*hls_sink_t)(const uint8_t *data, size_t len, bool segment_start);
#define HLS_URL_MAX 384
esp_err_t hls_start(hls_sink_t sink); esp_err_t hls_start(hls_sink_t sink);
// The playlist URL to play ("" = none). A different URL restarts fetching with it.
void hls_set_url(const char *url);
// Stop fetching (current download is abandoned) until resumed. Reasons are independent: // Stop fetching (current download is abandoned) until resumed. Reasons are independent:
// 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)
+73 -8
View File
@@ -29,6 +29,7 @@ static const char *TAG = "player";
typedef enum { MODE_TONE, MODE_OFF, MODE_HLS, MODE_SPOTIFY, MODE_AUTO } mode_t_; typedef enum { MODE_TONE, MODE_OFF, MODE_HLS, MODE_SPOTIFY, MODE_AUTO } mode_t_;
static const char *const SRC_NAME[] = { "tone", "off", "hls", "spotify" }; static const char *const SRC_NAME[] = { "tone", "off", "hls", "spotify" };
static const char *const MODE_NAME[] = { "tone", "off", "hls", "spotify", "auto" };
static volatile mode_t_ s_mode = MODE_OFF; static volatile mode_t_ s_mode = MODE_OFF;
static volatile player_src_t s_src = PLAYER_SRC_OFF; // what is playing now static volatile player_src_t s_src = PLAYER_SRC_OFF; // what is playing now
@@ -40,6 +41,12 @@ static volatile bool s_sp_playing; // Spotify has started playback and is n
static volatile bool s_hls_paused; // HLS paused via the API: not fetching, silence 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;
// Effective mode: the config's, or a runtime override from the API (not saved). Web server task.
static mode_t_ s_cfg_mode = MODE_OFF;
static char s_cfg_url[HLS_URL_MAX];
static int s_forced = -1; // mode forced via /api/player/source|url, -1 = none
static char s_forced_url[HLS_URL_MAX]; // from /api/player/url; "" = the config's URL
// Failover (mode auto) // Failover (mode auto)
static volatile int s_failover_delay_s = 5; static volatile int s_failover_delay_s = 5;
static volatile bool s_failover_on_pause; static volatile bool s_failover_on_pause;
@@ -173,14 +180,22 @@ static void select_source(player_src_t src)
s_target = src; // player_read fades out and switches s_target = src; // player_read fades out and switches
} }
void player_apply(const cJSON *source) // Apply the effective mode: Spotify on/off, the HLS URL, and the source to play.
static void apply_mode(void)
{ {
if (s_spotify_conv) { // after player_init: Spotify follows mode / name / credentials live static char applied_url[HLS_URL_MAX];
spotify_apply(); mode_t_ mode = s_forced >= 0 ? (mode_t_)s_forced : s_cfg_mode;
if (s_spotify_conv) { // after spotify_init: follows mode / name / credentials live
spotify_apply(mode == MODE_SPOTIFY || mode == MODE_AUTO);
}
const char *url = mode != MODE_HLS && mode != MODE_AUTO ? "" : s_forced_url[0] ? s_forced_url : s_cfg_url;
if (strcmp(url, applied_url)) {
strlcpy(applied_url, url, sizeof(applied_url));
hls_set_url(url);
if (s_src == PLAYER_SRC_HLS) {
s_flush = true; // another stream: don't finish the old one's buffer
}
} }
s_failover_delay_s = (int)cJSON_GetObjectItemCaseSensitive(source, "failover_delay_s")->valuedouble;
s_failover_on_pause = cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(source, "failover_on_pause"));
mode_t_ mode = mode_of(cJSON_GetObjectItemCaseSensitive(source, "mode")->valuestring);
if (mode == s_mode) { if (mode == s_mode) {
return; return;
} }
@@ -196,6 +211,15 @@ void player_apply(const cJSON *source)
} }
} }
void player_apply(const cJSON *source)
{
s_failover_delay_s = (int)cJSON_GetObjectItemCaseSensitive(source, "failover_delay_s")->valuedouble;
s_failover_on_pause = cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(source, "failover_on_pause"));
s_cfg_mode = mode_of(cJSON_GetObjectItemCaseSensitive(source, "mode")->valuestring);
strlcpy(s_cfg_url, cJSON_GetObjectItemCaseSensitive(source, "hls_url")->valuestring, sizeof(s_cfg_url));
apply_mode();
}
/* ----- source side ----- */ /* ----- source side ----- */
// Write converted 48 kHz frames, waiting for space at playback speed. Frames of a source that is // Write converted 48 kHz frames, waiting for space at playback speed. Frames of a source that is
@@ -369,7 +393,7 @@ static esp_err_t send_player(httpd_req_t *req)
bool sp = src == PLAYER_SRC_SPOTIFY && spotify_now_playing(&t); bool sp = src == PLAYER_SRC_SPOTIFY && spotify_now_playing(&t);
cJSON *o = cJSON_CreateObject(); cJSON *o = cJSON_CreateObject();
cJSON_AddStringToObject(o, "source", SRC_NAME[src]); cJSON_AddStringToObject(o, "source", SRC_NAME[src]);
cJSON_AddNullToObject(o, "forced"); add_str_or_null(o, "forced", s_forced >= 0 ? MODE_NAME[s_forced] : NULL);
cJSON_AddStringToObject(o, "state", src == PLAYER_SRC_OFF ? "stopped" : source_state(src)); cJSON_AddStringToObject(o, "state", src == PLAYER_SRC_OFF ? "stopped" : source_state(src));
add_str_or_null(o, "artist", sp ? t.artist : NULL); add_str_or_null(o, "artist", sp ? t.artist : NULL);
add_str_or_null(o, "title", sp ? t.title : NULL); add_str_or_null(o, "title", sp ? t.title : NULL);
@@ -438,7 +462,7 @@ static const char *transport(const char *cmd)
// Small JSON body of a POST; NULL if missing or invalid. // Small JSON body of a POST; NULL if missing or invalid.
static cJSON *read_body(httpd_req_t *req) static cJSON *read_body(httpd_req_t *req)
{ {
char buf[128]; char buf[HLS_URL_MAX + 128]; // fits {"url": ...}
if (req->content_len == 0 || req->content_len >= sizeof(buf)) { if (req->content_len == 0 || req->content_len >= sizeof(buf)) {
return NULL; return NULL;
} }
@@ -490,6 +514,43 @@ static esp_err_t seek_or_volume(httpd_req_t *req, bool seek)
return send_player(req); return send_player(req);
} }
// POST /api/player/source {source: spotify|hls|off|config}, /api/player/url {url}: runtime override
static esp_err_t force_source(httpd_req_t *req, bool url)
{
cJSON *body = read_body(req);
const cJSON *v = cJSON_GetObjectItemCaseSensitive(body, url ? "url" : "source");
const char *s = cJSON_IsString(v) ? v->valuestring : "";
const char *err = NULL;
if (url) {
if (strncmp(s, "http://", 7) && strncmp(s, "https://", 8)) {
err = "expected {\"url\": \"http(s)://...m3u8\"}";
} else if (strlen(s) >= sizeof(s_forced_url)) {
err = "url too long";
} else {
s_forced = MODE_HLS;
strlcpy(s_forced_url, s, sizeof(s_forced_url));
if (s_hls_paused && s_src == PLAYER_SRC_HLS) {
hls_pause(false); // a new stream starts playing
}
}
} else if (!strcmp(s, "config")) {
s_forced = -1;
s_forced_url[0] = '\0';
} else if (!strcmp(s, "spotify") || !strcmp(s, "hls") || !strcmp(s, "off")) {
s_forced = mode_of(s);
s_forced_url[0] = '\0';
} else {
err = "expected {\"source\": \"spotify|hls|off|config\"}";
}
if (!err) {
ESP_LOGI(TAG, "override: %s%s%s", s_forced >= 0 ? MODE_NAME[s_forced] : "none (config)",
s_forced_url[0] ? " " : "", s_forced_url);
apply_mode();
}
cJSON_Delete(body);
return err ? httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, err) : 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)
{ {
@@ -511,6 +572,9 @@ static esp_err_t player_post(httpd_req_t *req)
if (!strcmp(name, "seek") || !strcmp(name, "volume")) { if (!strcmp(name, "seek") || !strcmp(name, "volume")) {
return seek_or_volume(req, name[0] == 's'); return seek_or_volume(req, name[0] == 's');
} }
if (!strcmp(name, "source") || !strcmp(name, "url")) {
return force_source(req, name[0] == 'u');
}
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");
} }
@@ -535,6 +599,7 @@ esp_err_t player_init(void)
return ESP_ERR_NO_MEM; return ESP_ERR_NO_MEM;
} }
spotify_init(spotify_pcm, spotify_event, audio_ring_written, audio_ring_read_pos); spotify_init(spotify_pcm, spotify_event, audio_ring_written, audio_ring_read_pos);
apply_mode(); // now with Spotify
aes67_ota_on_update(on_ota); aes67_ota_on_update(on_ota);
err = hls_start(decoder_feed); err = hls_start(decoder_feed);
if (err != ESP_OK) { if (err != ESP_OK) {