Compare commits
6 Commits
c6ecbbeb25
...
faf427c897
| Author | SHA1 | Date | |
|---|---|---|---|
| faf427c897 | |||
| e5c3c0ad8a | |||
| fd88e02e96 | |||
| a55a3ce14a | |||
| 8485ecb5b7 | |||
| 9e1ca98a5d |
@@ -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
|
||||
- [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.
|
||||
|
||||
## Phase 2 (parked)
|
||||
|
||||
@@ -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.
|
||||
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,
|
||||
spotify_pos_cb_t played);
|
||||
// Re-read the "source" config: enabled (advertised, sessions accepted) when mode is spotify/auto and
|
||||
// the client credentials are set; otherwise not advertised and a running session is ended.
|
||||
// A new device name ends the session and re-advertises.
|
||||
void spotify_apply(void);
|
||||
// Pause/resume the running session (the Spotify app follows). No-op without a session.
|
||||
void spotify_pause(bool pause);
|
||||
// Enable (advertised, sessions accepted) when the player's mode needs Spotify and the client
|
||||
// credentials are set; otherwise not advertised and a running session is ended. Re-reads name and
|
||||
// credentials from the "source" config: a new device name ends the session and re-advertises.
|
||||
void spotify_apply(bool enable);
|
||||
typedef enum {
|
||||
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.
|
||||
// Resume: back to what the config says. Used around firmware uploads.
|
||||
void spotify_suspend(bool suspend);
|
||||
@@ -42,6 +48,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);
|
||||
|
||||
+117
-15
@@ -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"
|
||||
@@ -32,6 +33,7 @@ static std::shared_ptr<cspot::LoginBlob> s_blob; // guarded by s_mutex
|
||||
static std::mutex s_mutex;
|
||||
static std::string s_name; // advertised device name ("" = not advertised)
|
||||
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_got_blob{false};
|
||||
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::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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
if (!suspend) {
|
||||
spotify_apply();
|
||||
spotify_apply(s_want);
|
||||
return;
|
||||
}
|
||||
s_enabled = false;
|
||||
@@ -114,19 +129,71 @@ extern "C" void spotify_set_volume(int pct)
|
||||
return;
|
||||
}
|
||||
s_volume = v;
|
||||
auto h = s_handler;
|
||||
if (h) {
|
||||
h->setRemoteVolume(v);
|
||||
if (s_handler) {
|
||||
std::lock_guard<std::mutex> lock(s_cmd_mutex);
|
||||
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 (h) {
|
||||
h->setPause(pause);
|
||||
ESP_LOGI(TAG, "%s", pause ? "paused" : "resumed");
|
||||
if (!s_handler) {
|
||||
return false;
|
||||
}
|
||||
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)
|
||||
@@ -134,6 +201,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 +415,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:
|
||||
@@ -364,12 +457,22 @@ static void session(void)
|
||||
ESP_LOGI(TAG, "connected as %s", ctx->config.username.c_str());
|
||||
clear_boundaries(true);
|
||||
s_depleted = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(s_cmd_mutex);
|
||||
s_cmds.clear();
|
||||
s_cmd_done = s_cmd_queued;
|
||||
}
|
||||
while (!s_stop) {
|
||||
ctx->session->handlePacket(); // waits at most 200 ms
|
||||
run_commands(handler);
|
||||
check_playback(handler);
|
||||
}
|
||||
// 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");
|
||||
}
|
||||
@@ -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");
|
||||
std::string mode = cJSON_GetObjectItemCaseSensitive(src, "mode")->valuestring;
|
||||
std::string name = cJSON_GetObjectItemCaseSensitive(src, "spotify_name")->valuestring;
|
||||
bool creds = cJSON_GetObjectItemCaseSensitive(src, "spotify_client_id")->valuestring[0] &&
|
||||
cJSON_GetObjectItemCaseSensitive(src, "spotify_client_secret")->valuestring[0];
|
||||
cJSON_Delete(src);
|
||||
bool want = (mode == "spotify" || mode == "auto") && creds;
|
||||
bool want = enable && creds;
|
||||
|
||||
if (!want || name != s_name) {
|
||||
// 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) {
|
||||
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)");
|
||||
}
|
||||
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) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
spotify_apply();
|
||||
return ESP_OK;
|
||||
return ESP_OK; // the player enables it (spotify_apply)
|
||||
}
|
||||
|
||||
@@ -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/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)
|
||||
- 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.
|
||||
- No auth yet: LAN only.
|
||||
|
||||
+14
-10
@@ -4,7 +4,6 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "aes67_cfg.h"
|
||||
#include "esp_crt_bundle.h"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_http_client.h"
|
||||
@@ -13,7 +12,7 @@
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
#define URL_MAX 384
|
||||
#define URL_MAX HLS_URL_MAX
|
||||
#define PLAYLIST_MAX (64 * 1024)
|
||||
#define MAX_SEGMENTS 64
|
||||
#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 char *s_text; // playlist buffer (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 uint8_t *s_seg; // current segment (PSRAM)
|
||||
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)
|
||||
{
|
||||
cJSON *src = cfg_get("source");
|
||||
const char *mode = cJSON_GetObjectItemCaseSensitive(src, "mode")->valuestring;
|
||||
const char *url = cJSON_GetObjectItemCaseSensitive(src, "hls_url")->valuestring;
|
||||
bool on = !strcmp(mode, "hls") || !strcmp(mode, "auto");
|
||||
strlcpy(out, on ? url : "", n);
|
||||
cJSON_Delete(src);
|
||||
taskENTER_CRITICAL(&s_url_lock);
|
||||
strlcpy(out, s_want_url, n);
|
||||
taskEXIT_CRITICAL(&s_url_lock);
|
||||
}
|
||||
|
||||
static bool still_wanted(void)
|
||||
|
||||
@@ -10,9 +10,14 @@
|
||||
// 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);
|
||||
|
||||
#define HLS_URL_MAX 384
|
||||
|
||||
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:
|
||||
// fetching runs only when no reason holds it.
|
||||
#define HLS_SUSPEND_OTA (1 << 0)
|
||||
#define HLS_SUSPEND_FAILOVER (1 << 1)
|
||||
#define HLS_SUSPEND_USER (1 << 2) // paused via the player API
|
||||
void hls_suspend(unsigned reason, bool suspend);
|
||||
|
||||
+264
-10
@@ -29,6 +29,7 @@ static const char *TAG = "player";
|
||||
|
||||
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 MODE_NAME[] = { "tone", "off", "hls", "spotify", "auto" };
|
||||
|
||||
static volatile mode_t_ s_mode = MODE_OFF;
|
||||
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_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_hls_paused; // HLS paused via the API: not fetching, silence
|
||||
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)
|
||||
static volatile int s_failover_delay_s = 5;
|
||||
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_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));
|
||||
return frames;
|
||||
}
|
||||
@@ -158,6 +167,10 @@ static void select_source(player_src_t src)
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
s_target = s_src = src;
|
||||
s_flush = true;
|
||||
@@ -167,14 +180,22 @@ static void select_source(player_src_t src)
|
||||
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
|
||||
spotify_apply();
|
||||
static char applied_url[HLS_URL_MAX];
|
||||
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) {
|
||||
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 ----- */
|
||||
|
||||
// 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;
|
||||
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_target != src) {
|
||||
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)
|
||||
{
|
||||
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]);
|
||||
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 err = audio_ring_init(RING_FRAMES, CHANNELS);
|
||||
@@ -356,6 +599,7 @@ esp_err_t player_init(void)
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
spotify_init(spotify_pcm, spotify_event, audio_ring_written, audio_ring_read_pos);
|
||||
apply_mode(); // now with Spotify
|
||||
aes67_ota_on_update(on_ota);
|
||||
err = hls_start(decoder_feed);
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
|
||||
+25
-9
@@ -20,13 +20,14 @@ input:not([type=checkbox]),select{width:14em;max-width:100%}
|
||||
textarea{width:100%;height:14em;font:12px monospace}
|
||||
table td:first-child{padding-right:1em;opacity:.7}
|
||||
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}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2 id="title">AES67 device</h2>
|
||||
<p id="msg"></p>
|
||||
|
||||
<fieldset><legend>Status</legend><table id="status"></table></fieldset>
|
||||
<fieldset><legend>PTP status</legend><table id="ptpstat"></table>
|
||||
@@ -39,11 +40,11 @@ pre{overflow-x:auto}
|
||||
<button onclick="ctl('toggle')">⏯</button>
|
||||
<button onclick="ctl('next')">⏭</button>
|
||||
<button onclick="ctl('stop')">⏹</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"
|
||||
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>
|
||||
GET /api/player state, track, position, volume
|
||||
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>
|
||||
</fieldset>
|
||||
|
||||
<div class="bar">
|
||||
<button>Save</button>
|
||||
<button type="button" onclick="load()">Revert</button>
|
||||
<button type="button" onclick="confirm('Reboot device?')&&api('POST','/api/reboot')">Reboot</button>
|
||||
<span id="msg"></span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<fieldset><legend>SDP</legend>
|
||||
@@ -237,15 +241,27 @@ const PROJECT = {
|
||||
async function ctl(cmd, body) {
|
||||
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) {
|
||||
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 =
|
||||
`[${p.source}] ${p.state}: ${[p.artist, p.title].filter(Boolean).join(' – ') || '–'}` +
|
||||
(p.duration_ms ? ` ${t(p.position_ms)} / ${t(p.duration_ms)}` : '');
|
||||
`[${p.source}] ${p.state}: ${[p.artist, p.title].filter(Boolean).join(' – ') || '–'}`;
|
||||
const v = document.getElementById('vol');
|
||||
if (document.activeElement !== v) v.value = p.volume;
|
||||
document.getElementById('force').value = p.forced || 'config';
|
||||
}
|
||||
/* ===== PROJECT END ===== */
|
||||
|
||||
|
||||
Reference in New Issue
Block a user