Compare commits

..

6 Commits

Author SHA1 Message Date
bsncubed faf427c897 Web UI: sticky save bar; drop the Force source dropdown
Save/Revert/Reboot and the status message stay at the bottom of the window
while the settings form is in view. The runtime source override stays in
the API (listed in the page's API box) but is no longer on the page, where
it duplicated the saved source mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 23:20:41 +10:00
bsncubed e5c3c0ad8a 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>
2026-09-25 23:15:38 +10:00
bsncubed fd88e02e96 Web UI: player progress bar with seek
Follows /api/player's position, moves on locally between polls while
playing, seeks on release; disabled when the source can't seek.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 23:07:39 +10:00
bsncubed a55a3ce14a Player: seek and volume on /api/player
seek {ms} runs on the Spotify session task like a seek from the app (buffer
flushed, app notified). volume {value}|{delta} sets the player volume and
pushes it to the session, so the app's slider follows; setRemoteVolume now
also runs on the session task.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 23:04:11 +10:00
bsncubed 8485ecb5b7 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>
2026-09-25 22:59:14 +10:00
bsncubed 9e1ca98a5d 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>
2026-09-25 22:48:00 +10:00
9 changed files with 462 additions and 53 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 - [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)
+21 -7
View File
@@ -25,15 +25,21 @@ 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);
// Pause/resume the running session (the Spotify app follows). No-op without a session. typedef enum {
void spotify_pause(bool pause); SPOTIFY_CMD_PLAY, SPOTIFY_CMD_PAUSE, SPOTIFY_CMD_NEXT, SPOTIFY_CMD_PREV,
SPOTIFY_CMD_SEEK, // arg: position in ms
} spotify_cmd_t;
// 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, uint32_t arg);
// 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);
@@ -42,6 +48,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);
+117 -15
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"
@@ -32,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)
@@ -55,6 +57,19 @@ 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;
enum { CMD_VOLUME = 100 }; // internal: push s_volume to the app
struct Command {
int cmd; // spotify_cmd_t or CMD_VOLUME
uint32_t arg;
};
static std::deque<Command> 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 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);
@@ -93,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;
@@ -114,19 +129,71 @@ extern "C" void spotify_set_volume(int pct)
return; return;
} }
s_volume = v; s_volume = v;
auto h = s_handler; if (s_handler) {
if (h) { std::lock_guard<std::mutex> lock(s_cmd_mutex);
h->setRemoteVolume(v); s_cmds.push_back({ CMD_VOLUME, 0 });
++s_cmd_queued;
} }
} }
extern "C" void spotify_pause(bool pause) extern "C" bool spotify_command(spotify_cmd_t cmd, uint32_t arg)
{ {
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, arg });
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<Command> cmds;
uint32_t seq;
{
std::lock_guard<std::mutex> lock(s_cmd_mutex);
cmds.swap(s_cmds);
seq = s_cmd_queued;
}
for (auto [cmd, arg] : 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;
case SPOTIFY_CMD_SEEK:
// As for a seek from the app (SpircHandler::handleFrame): the buffered audio is void.
handler->getTrackPlayer()->seekMs(arg ? arg : 1); // 0 means "no seek" to TrackPlayer
handler->updatePositionMs(arg); // notifies the app
clear_boundaries(false);
s_depleted = false;
if (s_event_cb) {
s_event_cb(SPOTIFY_EV_FLUSH, 0);
}
break;
case CMD_VOLUME:
handler->setRemoteVolume(s_volume); // notifies the app
break;
}
ESP_LOGI(TAG, "command %d %lu", cmd, (unsigned long)arg);
}
s_cmd_done = seq;
} }
extern "C" bool spotify_session_active(void) extern "C" bool spotify_session_active(void)
@@ -134,6 +201,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 +415,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:
@@ -364,12 +457,22 @@ 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.
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");
} }
@@ -398,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.
@@ -421,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;
@@ -456,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;
} }
+1 -1
View File
@@ -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.
+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)
+5
View File
@@ -10,9 +10,14 @@
// 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)
#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);
+264 -10
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
@@ -37,8 +38,15 @@ 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;
// 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;
@@ -107,7 +115,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 +167,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;
@@ -167,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;
} }
@@ -190,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
@@ -198,6 +228,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
@@ -323,18 +356,228 @@ 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" :
src == PLAYER_SRC_HLS && s_hls_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]);
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));
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 || src == PLAYER_SRC_HLS);
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);
}
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, 0) ? 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";
}
// Small JSON body of a POST; NULL if missing or invalid.
static cJSON *read_body(httpd_req_t *req)
{
char buf[HLS_URL_MAX + 128]; // fits {"url": ...}
if (req->content_len == 0 || req->content_len >= sizeof(buf)) {
return NULL;
}
size_t got = 0;
while (got < req->content_len) {
int r = httpd_req_recv(req, buf + got, req->content_len - got);
if (r == HTTPD_SOCK_ERR_TIMEOUT) {
continue;
}
if (r <= 0) {
return NULL;
}
got += r;
}
buf[got] = '\0';
return cJSON_Parse(buf);
}
static esp_err_t send_409(httpd_req_t *req, const char *msg)
{
httpd_resp_set_status(req, "409 Conflict");
return httpd_resp_sendstr(req, msg);
}
// POST /api/player/seek {ms}, /api/player/volume {value} or {delta}
static esp_err_t seek_or_volume(httpd_req_t *req, bool seek)
{
cJSON *body = read_body(req);
const cJSON *v = cJSON_GetObjectItemCaseSensitive(body, seek ? "ms" : "value");
const cJSON *d = seek ? NULL : cJSON_GetObjectItemCaseSensitive(body, "delta");
double val = cJSON_IsNumber(v) ? v->valuedouble : cJSON_IsNumber(d) ? s_volume_pct + d->valuedouble : -1;
cJSON_Delete(body);
if (val < 0 && !(cJSON_IsNumber(d) && !seek)) {
return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, seek ? "expected {\"ms\": n}" :
"expected {\"value\": 0-100} or {\"delta\": n}");
}
if (seek) {
if (s_src != PLAYER_SRC_SPOTIFY) {
return send_409(req, "source can't seek");
}
if (!spotify_command(SPOTIFY_CMD_SEEK, (uint32_t)val)) {
return send_409(req, "no Spotify session");
}
} else {
int pct = val < 0 ? 0 : val > 100 ? 100 : (int)(val + 0.5);
set_volume(pct);
spotify_set_volume(pct); // the app follows (if a session runs)
}
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>
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);
return err ? send_409(req, err) : send_player(req);
}
}
if (!strcmp(name, "seek") || !strcmp(name, "volume")) {
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");
}
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);
@@ -356,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) {
@@ -364,5 +608,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 uris[] = {
{ .uri = "/api/player", .method = HTTP_GET, .handler = player_get },
{ .uri = "/api/player/*", .method = HTTP_POST, .handler = player_post },
};
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);
} }
+25 -9
View File
@@ -20,13 +20,14 @@ input:not([type=checkbox]),select{width:14em;max-width:100%}
textarea{width:100%;height:14em;font:12px monospace} textarea{width:100%;height:14em;font:12px monospace}
table td:first-child{padding-right:1em;opacity:.7} table td:first-child{padding-right:1em;opacity:.7}
pre{overflow-x:auto} pre{overflow-x:auto}
#msg{min-height:1.4em} #msg{margin-left:1em}
/* Save bar: stays at the bottom of the window while the settings form is in view */
.bar{position:sticky;bottom:0;background:Canvas;padding:8px 0;border-top:1px solid color-mix(in srgb,CanvasText 20%,transparent)}
.ok{color:#2a2}.warn{color:#d80}.bad{color:#e33} .ok{color:#2a2}.warn{color:#d80}.bad{color:#e33}
</style> </style>
</head> </head>
<body> <body>
<h2 id="title">AES67 device</h2> <h2 id="title">AES67 device</h2>
<p id="msg"></p>
<fieldset><legend>Status</legend><table id="status"></table></fieldset> <fieldset><legend>Status</legend><table id="status"></table></fieldset>
<fieldset><legend>PTP status</legend><table id="ptpstat"></table> <fieldset><legend>PTP status</legend><table id="ptpstat"></table>
@@ -39,11 +40,11 @@ pre{overflow-x:auto}
<button onclick="ctl('toggle')">&#x23EF;</button> <button onclick="ctl('toggle')">&#x23EF;</button>
<button onclick="ctl('next')">&#x23ED;</button> <button onclick="ctl('next')">&#x23ED;</button>
<button onclick="ctl('stop')">&#x23F9;</button> <button onclick="ctl('stop')">&#x23F9;</button>
<label><span>Position</span><input id="pos" type="range" min="0" max="0" value="0" step="1000" disabled
oninput="seekDrag=true; showPos()" onchange="seekDrag=false; ctl('seek',{ms:+this.value})">
<small id="postime"></small></label>
<label><span>Volume</span><input id="vol" type="range" min="0" max="100" <label><span>Volume</span><input id="vol" type="range" min="0" max="100"
onchange="ctl('volume',{value:+this.value})"></label> onchange="ctl('volume',{value:+this.value})"></label>
<label><span>Force source</span><select id="force" onchange="ctl('source',{source:this.value})">
<option value="config">As configured</option><option value="spotify">Spotify</option>
<option value="hls">HLS</option><option value="off">Off</option></select></label>
<details><summary>API</summary><pre> <details><summary>API</summary><pre>
GET /api/player state, track, position, volume GET /api/player state, track, position, volume
POST /api/player/play|pause|toggle|stop|next|prev POST /api/player/play|pause|toggle|stop|next|prev
@@ -185,9 +186,12 @@ e.g. curl -X POST http://p4-aes67.local/api/player/next
<button type="button" onclick="api('POST','/api/log/test').then(()=>msg.textContent='Test message sent.',()=>{})">Send test message</button> <button type="button" onclick="api('POST','/api/log/test').then(()=>msg.textContent='Test message sent.',()=>{})">Send test message</button>
</fieldset> </fieldset>
<div class="bar">
<button>Save</button> <button>Save</button>
<button type="button" onclick="load()">Revert</button> <button type="button" onclick="load()">Revert</button>
<button type="button" onclick="confirm('Reboot device?')&&api('POST','/api/reboot')">Reboot</button> <button type="button" onclick="confirm('Reboot device?')&&api('POST','/api/reboot')">Reboot</button>
<span id="msg"></span>
</div>
</form> </form>
<fieldset><legend>SDP</legend> <fieldset><legend>SDP</legend>
@@ -237,15 +241,27 @@ const PROJECT = {
async function ctl(cmd, body) { async function ctl(cmd, body) {
try { showPlayer(await api('POST', '/api/player/' + cmd, body)); } catch {} try { showPlayer(await api('POST', '/api/player/' + cmd, body)); } catch {}
} }
// Progress bar: follows the polled position, moves on locally while playing, seeks on release.
let np = null, npAt = 0, seekDrag = false;
const mmss = ms => `${Math.floor(ms/60000)}:${String(Math.floor(ms/1000)%60).padStart(2,'0')}`;
function showPos() {
const pos = document.getElementById('pos'), dur = np?.duration_ms;
pos.disabled = !(dur && np.can?.seek);
if (!seekDrag) {
const at = !dur ? 0 : np.state === 'playing' ? Math.min(np.position_ms + Date.now() - npAt, dur) : np.position_ms;
pos.max = dur || 0;
pos.value = at;
}
document.getElementById('postime').textContent = dur ? `${mmss(+pos.value)} / ${mmss(dur)}` : '';
}
setInterval(showPos, 500);
function showPlayer(p) { function showPlayer(p) {
if (!p || !p.state) return; if (!p || !p.state) return;
const t = s => s == null ? '' : `${Math.floor(s/60000)}:${String(Math.floor(s/1000)%60).padStart(2,'0')}`; np = p; npAt = Date.now(); showPos();
document.getElementById('np').textContent = document.getElementById('np').textContent =
`[${p.source}] ${p.state}: ${[p.artist, p.title].filter(Boolean).join(' – ') || '–'}` + `[${p.source}] ${p.state}: ${[p.artist, p.title].filter(Boolean).join(' – ') || '–'}`;
(p.duration_ms ? ` ${t(p.position_ms)} / ${t(p.duration_ms)}` : '');
const v = document.getElementById('vol'); const v = document.getElementById('vol');
if (document.activeElement !== v) v.value = p.volume; if (document.activeElement !== v) v.value = p.volume;
document.getElementById('force').value = p.forced || 'config';
} }
/* ===== PROJECT END ===== */ /* ===== PROJECT END ===== */