e5c3c0ad8a
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>
623 lines
23 KiB
C
623 lines
23 KiB
C
#include "player.h"
|
|
|
|
#include <math.h>
|
|
#include <string.h>
|
|
|
|
#include "aes67_cfg.h"
|
|
#include "aes67_ota.h"
|
|
#include "aes67_tx.h"
|
|
#include "aes67_web.h"
|
|
#include "audio_out.h"
|
|
#include "audio_ring.h"
|
|
#include "decoder.h"
|
|
#include "esp_log.h"
|
|
#include "esp_timer.h"
|
|
#include "freertos/FreeRTOS.h"
|
|
#include "freertos/task.h"
|
|
#include "hls.h"
|
|
#include "spotify.h"
|
|
|
|
#define RATE 48000
|
|
#define CHANNELS 2
|
|
#define RING_FRAMES (4 * RATE) // 4 s in PSRAM
|
|
#define PREFILL_FRAMES (RATE) // 1 s before (re)starting output
|
|
#define FADE_FRAMES (RATE * 30 / 1000) // 30 ms ramp at a source switch
|
|
#define SPOTIFY_WAIT_MS 100 // longest the Spotify data callback may wait (cspot holds a lock)
|
|
#define CONTROL_MS 250
|
|
|
|
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
|
|
static volatile player_src_t s_target = PLAYER_SRC_OFF; // switch to this (TX task, with fades)
|
|
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;
|
|
|
|
// TX task only
|
|
static int s_fade_out; // frames left in the fade-out before a switch
|
|
static int s_fade_in; // frames left in the fade-in after a switch
|
|
|
|
// Player volume, applied after the ring so a change is heard at once (not a ring length later).
|
|
static volatile float s_target_gain = 1.0f;
|
|
static float s_gain = 1.0f; // TX task only; ramps to s_target_gain
|
|
static volatile int s_volume_pct = 100;
|
|
|
|
// 0..100 % -> amplitude: cubic curve (about -18 dB at 50 %, 0 % = mute).
|
|
static void set_volume(int pct)
|
|
{
|
|
pct = pct < 0 ? 0 : pct > 100 ? 100 : pct;
|
|
float r = pct / 100.0f;
|
|
s_volume_pct = pct;
|
|
s_target_gain = r * r * r;
|
|
}
|
|
|
|
// Apply the volume, ramping linearly from the previous gain over this buffer (no zipper noise).
|
|
static void apply_gain(int32_t *buf, size_t frames)
|
|
{
|
|
float target = s_target_gain;
|
|
if (s_gain == target && target == 1.0f) {
|
|
return;
|
|
}
|
|
float step = (target - s_gain) / frames;
|
|
for (size_t i = 0; i < frames; i++) {
|
|
s_gain += step;
|
|
buf[i * 2] = (int32_t)(buf[i * 2] * s_gain);
|
|
buf[i * 2 + 1] = (int32_t)(buf[i * 2 + 1] * s_gain);
|
|
}
|
|
s_gain = target;
|
|
}
|
|
|
|
// Linear ramp over a fade: position counts down from FADE_FRAMES; fade-in rises, fade-out falls.
|
|
static void apply_fade(int32_t *buf, size_t frames, int *left, bool in)
|
|
{
|
|
for (size_t i = 0; i < frames && *left > 0; i++, (*left)--) {
|
|
float g = (float)(*left) / FADE_FRAMES; // 1 -> 0 over the fade
|
|
if (in) {
|
|
g = 1.0f - g;
|
|
}
|
|
buf[i * 2] = (int32_t)(buf[i * 2] * g);
|
|
buf[i * 2 + 1] = (int32_t)(buf[i * 2 + 1] * g);
|
|
}
|
|
}
|
|
|
|
static mode_t_ mode_of(const char *m)
|
|
{
|
|
return !strcmp(m, "tone") ? MODE_TONE : !strcmp(m, "hls") ? MODE_HLS : !strcmp(m, "spotify") ? MODE_SPOTIFY :
|
|
!strcmp(m, "auto") ? MODE_AUTO : MODE_OFF;
|
|
}
|
|
|
|
/* ----- output (AES67 TX pull callback, TX task, must not block) ----- */
|
|
|
|
// One buffer of the current source: ring audio, or silence while off / paused / buffering.
|
|
// Returns the frames produced (fewer = underrun).
|
|
static size_t read_current(int32_t *buf, size_t frames)
|
|
{
|
|
if (s_flush) {
|
|
audio_ring_flush();
|
|
s_flush = false;
|
|
s_playing = false;
|
|
}
|
|
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;
|
|
}
|
|
if (!s_playing) {
|
|
if (audio_ring_level() < PREFILL_FRAMES) {
|
|
memset(buf, 0, frames * CHANNELS * sizeof(int32_t));
|
|
return frames;
|
|
}
|
|
s_playing = true;
|
|
s_fade_in = FADE_FRAMES; // (re)start without a click
|
|
}
|
|
size_t got = audio_ring_read(buf, frames);
|
|
if (got < frames) {
|
|
s_playing = false; // ran dry: underrun (counted by TX), prefill again
|
|
}
|
|
return got;
|
|
}
|
|
|
|
static size_t player_read(int32_t *buf, size_t frames)
|
|
{
|
|
// A switch is due: fade the current source out, then switch (ring flushed, new source prefills).
|
|
if (s_target != s_src && s_fade_out == 0) {
|
|
s_fade_out = FADE_FRAMES;
|
|
}
|
|
size_t got = read_current(buf, frames);
|
|
if (s_fade_in > 0) {
|
|
apply_fade(buf, got, &s_fade_in, true);
|
|
}
|
|
if (s_fade_out > 0) {
|
|
apply_fade(buf, got, &s_fade_out, false);
|
|
if (s_fade_out == 0 || got < frames) {
|
|
s_fade_out = 0;
|
|
audio_ring_flush(); // before s_src changes: writers only write for the active source
|
|
s_playing = false;
|
|
s_flush = false;
|
|
s_src = s_target;
|
|
}
|
|
}
|
|
apply_gain(buf, got);
|
|
return got;
|
|
}
|
|
|
|
// Set the source to play. Tone comes from TX itself (phase-locked to PTP), so switches from/to it
|
|
// are immediate; everything else fades through player_read.
|
|
static void select_source(player_src_t src)
|
|
{
|
|
if (src == s_target) {
|
|
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;
|
|
aes67_tx_set_source(src == PLAYER_SRC_TONE ? NULL : player_read);
|
|
return;
|
|
}
|
|
s_target = src; // player_read fades out and switches
|
|
}
|
|
|
|
// Apply the effective mode: Spotify on/off, the HLS URL, and the source to play.
|
|
static void apply_mode(void)
|
|
{
|
|
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
|
|
}
|
|
}
|
|
if (mode == s_mode) {
|
|
return;
|
|
}
|
|
s_mode = mode;
|
|
// HLS fetches only in hls mode, or in auto while it is the active source (control task).
|
|
hls_suspend(HLS_SUSPEND_FAILOVER, mode == MODE_AUTO);
|
|
switch (mode) {
|
|
case MODE_TONE: select_source(PLAYER_SRC_TONE); break;
|
|
case MODE_HLS: select_source(PLAYER_SRC_HLS); break;
|
|
case MODE_SPOTIFY: select_source(PLAYER_SRC_SPOTIFY); break;
|
|
case MODE_AUTO: select_source(PLAYER_SRC_SPOTIFY); break; // Spotify inactive: HLS after the delay
|
|
default: select_source(PLAYER_SRC_OFF); break;
|
|
}
|
|
}
|
|
|
|
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
|
|
// neither playing nor about to play are dropped (returns n).
|
|
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
|
|
}
|
|
vTaskDelay(pdMS_TO_TICKS(5)); // switch in progress (fade-out, ~30 ms)
|
|
continue;
|
|
}
|
|
size_t w = audio_ring_write(frames + done * CHANNELS, n - done);
|
|
if (!w) {
|
|
vTaskDelay(pdMS_TO_TICKS(20));
|
|
}
|
|
done += w;
|
|
}
|
|
return done;
|
|
}
|
|
|
|
/* ----- Spotify ----- */
|
|
|
|
// cspot calls this holding a lock that stopping the session also needs: never wait long. Take only
|
|
// what fits in the ring; return 0 after SPOTIFY_WAIT_MS and cspot retries (it can stop in between).
|
|
// When it isn't Spotify's turn, hold cspot back the same way: it doesn't stop decoding on pause, so
|
|
// dropping frames would let it race through the queue at full speed (CPU and network), starving HLS.
|
|
static size_t spotify_pcm(const int16_t *pcm, size_t frames)
|
|
{
|
|
int64_t until = esp_timer_get_time() + SPOTIFY_WAIT_MS * 1000LL;
|
|
while (true) {
|
|
if (s_src == PLAYER_SRC_SPOTIFY) {
|
|
// 44.1 kHz in -> about 1.09x as many 48 kHz frames out; keep a margin
|
|
size_t fit = audio_ring_space() * 44100 / 48000;
|
|
fit = fit > 64 ? fit - 64 : 0;
|
|
size_t n = frames < fit ? frames : fit;
|
|
if (n) {
|
|
audio_conv_write(s_spotify_conv, pcm, n, 2, 44100);
|
|
return n;
|
|
}
|
|
}
|
|
if (esp_timer_get_time() >= until) {
|
|
return 0;
|
|
}
|
|
vTaskDelay(pdMS_TO_TICKS(10));
|
|
}
|
|
}
|
|
|
|
static void spotify_event(spotify_event_t ev, int value)
|
|
{
|
|
switch (ev) {
|
|
case SPOTIFY_EV_PLAY:
|
|
s_paused = false;
|
|
s_sp_playing = true;
|
|
break;
|
|
case SPOTIFY_EV_PAUSE:
|
|
s_paused = true;
|
|
s_sp_playing = false;
|
|
break;
|
|
case SPOTIFY_EV_FLUSH:
|
|
if (s_src == PLAYER_SRC_SPOTIFY) {
|
|
s_flush = true; // skip/seek: don't play out the old buffer
|
|
}
|
|
break;
|
|
case SPOTIFY_EV_VOLUME: {
|
|
// Spotify sends 0..65535; the app's slider stays the master of the player volume.
|
|
int pct = (value * 100 + 32767) / 65535;
|
|
set_volume(pct);
|
|
float g = s_target_gain;
|
|
ESP_LOGI(TAG, "volume %d%% (%.1f dB)", pct, g > 0 ? 20.0f * log10f(g) : -INFINITY);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ----- failover (mode auto) ----- */
|
|
|
|
// Spotify stays advertised; HLS plays whenever Spotify is inactive for failover_delay_s, and Spotify
|
|
// takes over as soon as it plays. HLS is stopped while Spotify plays (no bandwidth/CPU).
|
|
static void control_task(void *arg)
|
|
{
|
|
int64_t inactive_since = esp_timer_get_time();
|
|
while (true) {
|
|
vTaskDelay(pdMS_TO_TICKS(CONTROL_MS));
|
|
if (s_mode != MODE_AUTO) {
|
|
inactive_since = esp_timer_get_time();
|
|
continue;
|
|
}
|
|
bool session = spotify_session_active();
|
|
if (!session) {
|
|
s_sp_playing = false; // session ended (app left, error, disabled)
|
|
}
|
|
bool playing = session && s_sp_playing;
|
|
// Inactive: no session; with failover_on_pause also a connected but paused/stopped one.
|
|
bool inactive = !session || (s_failover_on_pause && !playing);
|
|
int64_t now = esp_timer_get_time();
|
|
if (!inactive) {
|
|
inactive_since = now;
|
|
}
|
|
if (playing && s_target != PLAYER_SRC_SPOTIFY) {
|
|
ESP_LOGI(TAG, "auto: Spotify is playing, switching to it");
|
|
hls_suspend(HLS_SUSPEND_FAILOVER, true);
|
|
select_source(PLAYER_SRC_SPOTIFY);
|
|
} else if (inactive && s_target == PLAYER_SRC_SPOTIFY &&
|
|
now - inactive_since >= (int64_t)s_failover_delay_s * 1000000) {
|
|
ESP_LOGI(TAG, "auto: Spotify inactive for %d s, failing over to HLS", s_failover_delay_s);
|
|
hls_suspend(HLS_SUSPEND_FAILOVER, false);
|
|
select_source(PLAYER_SRC_HLS);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Firmware upload: flash writes stall tasks for > 20 ms at a time; with a Spotify session running,
|
|
// uploads crawled and sometimes ended in a reset (pausing was not enough). End the session (its
|
|
// tasks and connections are gone before the first write) and stop HLS fetching meanwhile. The
|
|
// device reboots after a good upload anyway; after a failed one the sources come back (Spotify
|
|
// waits for the app to reconnect).
|
|
static void on_ota(bool starting)
|
|
{
|
|
if (starting) {
|
|
spotify_suspend(true);
|
|
hls_suspend(HLS_SUSPEND_OTA, true);
|
|
ESP_LOGI(TAG, "firmware update: Spotify session ended, HLS suspended");
|
|
} else {
|
|
hls_suspend(HLS_SUSPEND_OTA, false);
|
|
spotify_suspend(false);
|
|
ESP_LOGI(TAG, "firmware update failed: sources resumed");
|
|
}
|
|
}
|
|
|
|
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 = 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);
|
|
if (err != ESP_OK) {
|
|
ESP_LOGE(TAG, "ring buffer: %s", esp_err_to_name(err));
|
|
return err;
|
|
}
|
|
aes67_tx_set_source(player_read);
|
|
cJSON *src = cfg_get("source");
|
|
s_mode = (mode_t_)-1;
|
|
player_apply(src);
|
|
cJSON_Delete(src);
|
|
err = decoder_init();
|
|
if (err != ESP_OK) {
|
|
return err;
|
|
}
|
|
s_spotify_conv = audio_conv_create(PLAYER_SRC_SPOTIFY, "spotify");
|
|
if (!s_spotify_conv) {
|
|
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) {
|
|
return err;
|
|
}
|
|
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);
|
|
}
|