Files
esphome-lsm6ds/esphome/components/sendspin/media_source/sendspin_media_source.cpp
T
bc7f35b569 [sendspin] Add a Sendspin media source component for playing audio (PR4) (#15950)
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-04-24 10:00:22 +00:00

208 lines
8.0 KiB
C++

#include "sendspin_media_source.h"
#if defined(USE_ESP32) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER)
#include "esphome/components/audio/audio.h"
#include "esphome/core/log.h"
#include <cmath>
namespace esphome::sendspin_ {
static const char *const TAG = "sendspin.media_source";
static constexpr char URI_PREFIX[] = "sendspin://";
void SendspinMediaSource::setup() {
this->player_role_ = this->parent_->get_player_role();
if (!this->player_role_) {
ESP_LOGE(TAG, "Failed to get player role from hub");
this->mark_failed();
return;
}
// Push cached states to player role. They may have been set before setup() ran.
this->player_role_->update_volume(std::roundf(this->cached_volume_ * 100.0f));
this->player_role_->update_muted(this->cached_muted_);
this->player_role_->set_static_delay_adjustable(this->static_delay_adjustable_);
}
void SendspinMediaSource::dump_config() {
ESP_LOGCONFIG(TAG, "Sendspin Media Source: static_delay_adjustable=%s", YESNO(this->static_delay_adjustable_));
}
// THREAD CONTEXT: Main loop (invoked from ESPHome actions / config)
void SendspinMediaSource::set_static_delay_adjustable(bool adjustable) {
this->static_delay_adjustable_ = adjustable;
if (this->player_role_) {
this->player_role_->set_static_delay_adjustable(adjustable);
}
}
// --- MediaSource interface ---
bool SendspinMediaSource::can_handle(const std::string &uri) const { return uri.starts_with(URI_PREFIX); }
// THREAD CONTEXT: Main loop (media_source.h documents play_uri as main-loop only)
bool SendspinMediaSource::play_uri(const std::string &uri) {
if (!this->is_ready() || this->is_failed() || !this->has_listener()) {
return false;
}
if (this->get_state() != media_source::MediaSourceState::IDLE) {
ESP_LOGE(TAG, "Cannot play '%s': source is busy", uri.c_str());
return false;
}
if (!uri.starts_with(URI_PREFIX)) {
ESP_LOGE(TAG, "Invalid URI: '%s'", uri.c_str());
return false;
}
std::string sendspin_id = uri.substr(sizeof(URI_PREFIX) - 1);
if (sendspin_id.empty()) {
ESP_LOGE(TAG, "Invalid URI: '%s'", uri.c_str());
return false;
}
ESP_LOGD(TAG, "sendspin_id: %s", sendspin_id.c_str());
if (sendspin_id != "current") {
// Connect to a new server as a websocket client
this->parent_->connect_to_server("ws://" + sendspin_id);
}
// Tell the orchestrator we're now playing so it routes audio output from us
this->pending_start_ = false;
this->set_state_(media_source::MediaSourceState::PLAYING);
return true;
}
// THREAD CONTEXT: Main loop (media_source.h documents handle_command as main-loop only)
void SendspinMediaSource::handle_command(media_source::MediaSourceCommand command) {
switch (command) {
case media_source::MediaSourceCommand::STOP: {
if (!this->pending_start_) {
// Ignore stop commands if we have a pending start, since the orchestrator may send a stop command before
// play_uri
ESP_LOGD(TAG, "Received STOP command, updating Sendspin state to EXTERNAL_SOURCE");
this->parent_->update_state(sendspin::SendspinClientState::EXTERNAL_SOURCE);
}
break;
}
case media_source::MediaSourceCommand::PLAY: // NOLINT(bugprone-branch-clone)
this->parent_->send_client_command(sendspin::SendspinControllerCommand::PLAY, std::nullopt, std::nullopt);
break;
case media_source::MediaSourceCommand::PAUSE:
this->parent_->send_client_command(sendspin::SendspinControllerCommand::PAUSE, std::nullopt, std::nullopt);
break;
case media_source::MediaSourceCommand::NEXT:
this->parent_->send_client_command(sendspin::SendspinControllerCommand::NEXT, std::nullopt, std::nullopt);
break;
case media_source::MediaSourceCommand::PREVIOUS:
this->parent_->send_client_command(sendspin::SendspinControllerCommand::PREVIOUS, std::nullopt, std::nullopt);
break;
case media_source::MediaSourceCommand::REPEAT_ALL:
this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_ALL, std::nullopt, std::nullopt);
break;
case media_source::MediaSourceCommand::REPEAT_ONE:
this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_ONE, std::nullopt, std::nullopt);
break;
case media_source::MediaSourceCommand::REPEAT_OFF:
this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_OFF, std::nullopt, std::nullopt);
break;
case media_source::MediaSourceCommand::SHUFFLE:
this->parent_->send_client_command(sendspin::SendspinControllerCommand::SHUFFLE, std::nullopt, std::nullopt);
break;
case media_source::MediaSourceCommand::UNSHUFFLE:
this->parent_->send_client_command(sendspin::SendspinControllerCommand::UNSHUFFLE, std::nullopt, std::nullopt);
break;
default:
break;
}
}
// THREAD CONTEXT: Main loop (orchestrator -> source notification)
void SendspinMediaSource::notify_volume_changed(float volume) {
this->cached_volume_ = volume;
if (this->player_role_) {
this->player_role_->update_volume(std::roundf(volume * 100.0f));
}
}
// THREAD CONTEXT: Main loop (orchestrator -> source notification)
void SendspinMediaSource::notify_mute_changed(bool is_muted) {
this->cached_muted_ = is_muted;
if (this->player_role_) {
this->player_role_->update_muted(is_muted);
}
}
// THREAD CONTEXT: Speaker playback callback thread (forwarded from the speaker).
// PlayerRole::notify_audio_played() is documented as thread-safe for this use.
void SendspinMediaSource::notify_audio_played(uint32_t frames, int64_t timestamp) {
if (this->player_role_) {
this->player_role_->notify_audio_played(frames, timestamp);
}
}
// --- Sendspin PlayerRoleListener overrides ---
// THREAD CONTEXT: Sendspin sync task background thread. May block up to timeout_ms.
size_t SendspinMediaSource::on_audio_write(uint8_t *data, size_t length, uint32_t timeout_ms) {
if (!this->has_listener() || (this->get_state() != media_source::MediaSourceState::PLAYING)) {
vTaskDelay(pdMS_TO_TICKS(timeout_ms));
return 0;
}
// PlayerRole::get_current_stream_params() is safe to call from the sync task.
auto &params = this->player_role_->get_current_stream_params();
if (!params.bit_depth.has_value() || !params.channels.has_value() || !params.sample_rate.has_value()) {
vTaskDelay(pdMS_TO_TICKS(timeout_ms));
return 0;
}
audio::AudioStreamInfo stream_info(*params.bit_depth, *params.channels, *params.sample_rate);
return this->write_output(data, length, timeout_ms, stream_info);
}
// THREAD CONTEXT: Main loop (PlayerRoleListener lifecycle callback)
void SendspinMediaSource::on_stream_start() {
this->parent_->update_state(sendspin::SendspinClientState::SYNCHRONIZED);
if (!this->pending_start_) {
// Dedup rapid on_stream_start() calls
this->pending_start_ = true;
// Request the orchestrator to start this source
this->request_play_uri_("sendspin://current");
}
}
// THREAD CONTEXT: Main loop (PlayerRoleListener lifecycle callback)
void SendspinMediaSource::on_stream_end() {
if (this->get_state() != media_source::MediaSourceState::IDLE) {
// Only set to IDLE if we were previously in a non-IDLE state, to avoid duplicate state changes
this->set_state_(media_source::MediaSourceState::IDLE);
}
}
// THREAD CONTEXT: Main loop (PlayerRoleListener lifecycle callback)
void SendspinMediaSource::on_stream_clear() {
if (this->get_state() != media_source::MediaSourceState::IDLE) {
// Only set to IDLE if we were previously in a non-IDLE state, to avoid duplicate state changes
this->set_state_(media_source::MediaSourceState::IDLE);
}
}
// THREAD CONTEXT: Main loop (PlayerRoleListener callback)
void SendspinMediaSource::on_volume_changed(uint8_t volume) { this->request_volume_(volume / 100.0f); }
// THREAD CONTEXT: Main loop (PlayerRoleListener callback)
void SendspinMediaSource::on_mute_changed(bool muted) { this->request_mute_(muted); }
} // namespace esphome::sendspin_
#endif // USE_ESP32 && USE_SENDSPIN_PLAYER && USE_SENDSPIN_CONTROLLER