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:
@@ -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
|
||||
- [ ] /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.
|
||||
|
||||
## Phase 2 (parked)
|
||||
|
||||
@@ -42,6 +42,14 @@ void spotify_suspend(bool suspend);
|
||||
void spotify_set_volume(int pct);
|
||||
// A session is running (an app is connected to this device).
|
||||
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.
|
||||
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);
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include "BellLogger.h"
|
||||
#include "CSpotContext.h"
|
||||
#include "LoginBlob.h"
|
||||
#include "PlaybackState.h"
|
||||
#include "SpircHandler.h"
|
||||
#include "TrackPlayer.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::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)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(s_bound_mutex);
|
||||
@@ -134,6 +138,30 @@ extern "C" bool spotify_session_active(void)
|
||||
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)
|
||||
{
|
||||
return s_state.load();
|
||||
@@ -324,6 +352,8 @@ static void session(void)
|
||||
case E::TRACK_INFO: {
|
||||
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);
|
||||
std::lock_guard<std::mutex> lock(s_track_mutex);
|
||||
s_track = ti;
|
||||
return;
|
||||
}
|
||||
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.
|
||||
s_handler.reset();
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(s_track_mutex);
|
||||
s_track = {};
|
||||
}
|
||||
handler->disconnect();
|
||||
ESP_LOGI(TAG, "session ended");
|
||||
}
|
||||
|
||||
+60
-3
@@ -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)
|
||||
{
|
||||
player_src_t src = s_src;
|
||||
const char *state = 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";
|
||||
const char *state = source_state(src);
|
||||
cJSON_AddStringToObject(st, "active_source", SRC_NAME[src]);
|
||||
cJSON_AddStringToObject(st, "source_state", state);
|
||||
cJSON_AddStringToObject(st, "spotify_state", spotify_state());
|
||||
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 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) {
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user