From 9e1ca98a5d47e47b64244a24d3c4997ec0d063c5 Mon Sep 17 00:00:00 2001 From: Ben Nicholson Date: Fri, 25 Sep 2026 22:48:00 +1000 Subject: [PATCH] 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 --- CLAUDE.md | 2 +- components/spotify/include/spotify.h | 8 +++ .../cspot/0004-spirc-state-getters.patch | 14 +++++ components/spotify/spotify.cpp | 34 ++++++++++ main/player.c | 63 ++++++++++++++++++- 5 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 components/spotify/patches/cspot/0004-spirc-state-getters.patch diff --git a/CLAUDE.md b/CLAUDE.md index 6e68e16..42d4b82 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) diff --git a/components/spotify/include/spotify.h b/components/spotify/include/spotify.h index 520881d..617d908 100644 --- a/components/spotify/include/spotify.h +++ b/components/spotify/include/spotify.h @@ -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); diff --git a/components/spotify/patches/cspot/0004-spirc-state-getters.patch b/components/spotify/patches/cspot/0004-spirc-state-getters.patch new file mode 100644 index 0000000..cfbe1c1 --- /dev/null +++ b/components/spotify/patches/cspot/0004-spirc-state-getters.patch @@ -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 getTrackPlayer(); ++ // Read access for the host (now-playing info: track position, play state). ++ std::shared_ptr getPlaybackState() { return playbackState; } ++ std::shared_ptr getContext() { return ctx; } + + void setEventHandler(EventHandler handler); + diff --git a/components/spotify/spotify.cpp b/components/spotify/spotify.cpp index 690d459..37ba088 100644 --- a/components/spotify/spotify.cpp +++ b/components/spotify/spotify.cpp @@ -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 s_bounds; static std::string s_last_id; // track of the most recent data callback static std::atomic 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 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 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(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 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 lock(s_track_mutex); + s_track = {}; + } handler->disconnect(); ESP_LOGI(TAG, "session ended"); } diff --git a/main/player.c b/main/player.c index 88f2e1e..bb34f63 100644 --- a/main/player.c +++ b/main/player.c @@ -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); }