Player: GET /api/player with Spotify now-playing info

Source, state, artist/title/album, position and duration (from cspot's
playback state, so it matches the app), volume and `can`. cspot patch 0004
adds read getters for the playback state and context.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 22:48:00 +10:00
parent c6ecbbeb25
commit 9e1ca98a5d
5 changed files with 117 additions and 4 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 - [ ] /api/player: GET done (source, state, track, position as the app shows it, volume, can). Next: transport POSTs, seek/volume, source override/url.
- [ ] 8. Mono sum, gain, polish. - [ ] 8. Mono sum, gain, polish.
## Phase 2 (parked) ## Phase 2 (parked)
+8
View File
@@ -42,6 +42,14 @@ void spotify_suspend(bool suspend);
void spotify_set_volume(int pct); void spotify_set_volume(int pct);
// A session is running (an app is connected to this device). // A session is running (an app is connected to this device).
bool spotify_session_active(void); bool spotify_session_active(void);
typedef struct {
char artist[96], title[128], album[96]; // "" until the first track is audible
uint32_t duration_ms, position_ms; // position as the Spotify app sees it
bool paused;
} spotify_track_t;
// The current track of the running session. False without a session.
bool spotify_now_playing(spotify_track_t *t);
// Current state for status.spotify_state. // Current state for status.spotify_state.
const char *spotify_state(void); const char *spotify_state(void);
@@ -0,0 +1,14 @@
diff --git a/cspot/include/SpircHandler.h b/cspot/include/SpircHandler.h
index 7ff930b..c17caa0 100644
--- a/cspot/include/SpircHandler.h
+++ b/cspot/include/SpircHandler.h
@@ -44,6 +44,9 @@ class SpircHandler {
void subscribeToMercury();
std::shared_ptr<TrackPlayer> getTrackPlayer();
+ // Read access for the host (now-playing info: track position, play state).
+ std::shared_ptr<PlaybackState> getPlaybackState() { return playbackState; }
+ std::shared_ptr<cspot::Context> getContext() { return ctx; }
void setEventHandler(EventHandler handler);
+34
View File
@@ -12,6 +12,7 @@
#include "BellLogger.h" #include "BellLogger.h"
#include "CSpotContext.h" #include "CSpotContext.h"
#include "LoginBlob.h" #include "LoginBlob.h"
#include "PlaybackState.h"
#include "SpircHandler.h" #include "SpircHandler.h"
#include "TrackPlayer.h" #include "TrackPlayer.h"
#include "esp_log.h" #include "esp_log.h"
@@ -55,6 +56,9 @@ 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_track_mutex;
static cspot::TrackInfo s_track; // audible track (TRACK_INFO), guarded by s_track_mutex
static void clear_boundaries(bool new_load) static void clear_boundaries(bool new_load)
{ {
std::lock_guard<std::mutex> lock(s_bound_mutex); std::lock_guard<std::mutex> lock(s_bound_mutex);
@@ -134,6 +138,30 @@ extern "C" bool spotify_session_active(void)
return s_handler != nullptr; return s_handler != nullptr;
} }
extern "C" bool spotify_now_playing(spotify_track_t *t)
{
auto h = s_handler;
if (!h) {
return false;
}
{
std::lock_guard<std::mutex> lock(s_track_mutex);
strlcpy(t->artist, s_track.artist.c_str(), sizeof(t->artist));
strlcpy(t->title, s_track.name.c_str(), sizeof(t->title));
strlcpy(t->album, s_track.album.c_str(), sizeof(t->album));
t->duration_ms = s_track.duration;
}
// Same position the app shows: last reported position, plus the time since while playing.
const auto &st = h->getPlaybackState()->innerFrame.state;
t->paused = st.status != PlayStatus_kPlayStatusPlay;
uint64_t pos = st.position_ms;
if (!t->paused) {
pos += h->getContext()->timeProvider->getSyncedTimestamp() - st.position_measured_at;
}
t->position_ms = t->duration_ms && pos > t->duration_ms ? t->duration_ms : (uint32_t)pos;
return true;
}
const char *spotify_state(void) const char *spotify_state(void)
{ {
return s_state.load(); return s_state.load();
@@ -324,6 +352,8 @@ static void session(void)
case E::TRACK_INFO: { case E::TRACK_INFO: {
auto &ti = std::get<cspot::TrackInfo>(ev->data); auto &ti = std::get<cspot::TrackInfo>(ev->data);
ESP_LOGI(TAG, "track: %s - %s (%lu ms)", ti.artist.c_str(), ti.name.c_str(), (unsigned long)ti.duration); ESP_LOGI(TAG, "track: %s - %s (%lu ms)", ti.artist.c_str(), ti.name.c_str(), (unsigned long)ti.duration);
std::lock_guard<std::mutex> lock(s_track_mutex);
s_track = ti;
return; return;
} }
case E::PLAY_PAUSE: case E::PLAY_PAUSE:
@@ -370,6 +400,10 @@ static void session(void)
} }
// 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.
s_handler.reset(); s_handler.reset();
{
std::lock_guard<std::mutex> lock(s_track_mutex);
s_track = {};
}
handler->disconnect(); handler->disconnect();
ESP_LOGI(TAG, "session ended"); ESP_LOGI(TAG, "session ended");
} }
+60 -3
View File
@@ -323,18 +323,70 @@ static void on_ota(bool starting)
} }
} }
static const char *source_state(player_src_t src)
{
return src == PLAYER_SRC_TONE ? "playing" : src == PLAYER_SRC_OFF ? "idle" :
src == PLAYER_SRC_SPOTIFY && !spotify_session_active() ? "idle" :
src == PLAYER_SRC_SPOTIFY && s_paused ? "paused" : s_playing ? "playing" : "buffering";
}
static void player_status(cJSON *st) static void player_status(cJSON *st)
{ {
player_src_t src = s_src; player_src_t src = s_src;
const char *state = src == PLAYER_SRC_TONE ? "playing" : src == PLAYER_SRC_OFF ? "idle" : const char *state = source_state(src);
src == PLAYER_SRC_SPOTIFY && !spotify_session_active() ? "idle" :
src == PLAYER_SRC_SPOTIFY && s_paused ? "paused" : s_playing ? "playing" : "buffering";
cJSON_AddStringToObject(st, "active_source", SRC_NAME[src]); cJSON_AddStringToObject(st, "active_source", SRC_NAME[src]);
cJSON_AddStringToObject(st, "source_state", state); cJSON_AddStringToObject(st, "source_state", state);
cJSON_AddStringToObject(st, "spotify_state", spotify_state()); cJSON_AddStringToObject(st, "spotify_state", spotify_state());
cJSON_AddNumberToObject(st, "buffer_ms", (double)(audio_ring_level() * 1000 / RATE)); cJSON_AddNumberToObject(st, "buffer_ms", (double)(audio_ring_level() * 1000 / RATE));
} }
/* ----- Player API ----- */
static void add_str_or_null(cJSON *o, const char *key, const char *s)
{
if (s && s[0]) {
cJSON_AddStringToObject(o, key, s);
} else {
cJSON_AddNullToObject(o, key);
}
}
// Same JSON for GET /api/player and every POST.
static esp_err_t send_player(httpd_req_t *req)
{
player_src_t src = s_src;
spotify_track_t t;
bool sp = src == PLAYER_SRC_SPOTIFY && spotify_now_playing(&t);
cJSON *o = cJSON_CreateObject();
cJSON_AddStringToObject(o, "source", SRC_NAME[src]);
cJSON_AddNullToObject(o, "forced");
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, "title", sp ? t.title : NULL);
add_str_or_null(o, "album", sp ? t.album : NULL);
if (sp && t.duration_ms) {
cJSON_AddNumberToObject(o, "position_ms", t.position_ms);
cJSON_AddNumberToObject(o, "duration_ms", t.duration_ms);
} else {
cJSON_AddNullToObject(o, "position_ms");
cJSON_AddNullToObject(o, "duration_ms");
}
cJSON_AddNumberToObject(o, "volume", s_volume_pct);
cJSON *can = cJSON_AddObjectToObject(o, "can");
cJSON_AddBoolToObject(can, "pause", sp);
cJSON_AddBoolToObject(can, "next", sp);
cJSON_AddBoolToObject(can, "prev", sp);
cJSON_AddBoolToObject(can, "seek", sp);
esp_err_t err = web_send_json(req, o);
cJSON_Delete(o);
return err;
}
static esp_err_t player_get(httpd_req_t *req)
{
return send_player(req);
}
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);
@@ -364,5 +416,10 @@ 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 };
err = web_register_uri(&get);
if (err != ESP_OK) {
return err;
}
return status_register(player_status); return status_register(player_status);
} }