[speaker_source] Add playlist management (#14652)

This commit is contained in:
Kevin Ahrendt
2026-03-11 12:47:58 -05:00
committed by GitHub
parent c52a48ed38
commit 4e16f270a3
5 changed files with 429 additions and 95 deletions
@@ -0,0 +1,29 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_ESP32
#include "esphome/core/automation.h"
#include "speaker_source_media_player.h"
namespace esphome::speaker_source {
template<typename... Ts> class SetPlaylistDelayAction : public Action<Ts...> {
public:
explicit SetPlaylistDelayAction(SpeakerSourceMediaPlayer *parent) : parent_(parent) {}
TEMPLATABLE_VALUE(uint8_t, pipeline)
TEMPLATABLE_VALUE(uint32_t, delay)
void play(const Ts &...x) override {
this->parent_->set_playlist_delay_ms(this->pipeline_.value(x...), this->delay_.value(x...));
}
protected:
SpeakerSourceMediaPlayer *parent_;
};
} // namespace esphome::speaker_source
#endif // USE_ESP32
@@ -3,13 +3,16 @@ import esphome.codegen as cg
from esphome.components import audio, media_player, media_source, speaker
import esphome.config_validation as cv
from esphome.const import (
CONF_DELAY,
CONF_FORMAT,
CONF_ID,
CONF_NUM_CHANNELS,
CONF_SAMPLE_RATE,
CONF_SPEAKER,
)
from esphome.core import ID
from esphome.core.entity_helpers import inherit_property_from
from esphome.cpp_generator import MockObj, TemplateArgsType
from esphome.types import ConfigType
AUTO_LOAD = ["audio"]
@@ -19,6 +22,7 @@ CODEOWNERS = ["@kahrendt"]
CONF_MEDIA_PIPELINE = "media_pipeline"
CONF_ON_MUTE = "on_mute"
CONF_PIPELINE = "pipeline"
CONF_ON_UNMUTE = "on_unmute"
CONF_ON_VOLUME = "on_volume"
CONF_SOURCES = "sources"
@@ -36,6 +40,13 @@ SpeakerSourceMediaPlayer = speaker_source_ns.class_(
PipelineContext = speaker_source_ns.struct("PipelineContext")
Pipeline = speaker_source_ns.enum("Pipeline")
PIPELINE_ENUM = {
"media": Pipeline.MEDIA_PIPELINE,
}
SetPlaylistDelayAction = speaker_source_ns.class_(
"SetPlaylistDelayAction", automation.Action
)
FORMAT_MAPPING = {
@@ -210,3 +221,35 @@ async def to_code(config: ConfigType) -> None:
[(cg.float_, "x")],
on_volume,
)
SET_PLAYLIST_DELAY_ACTION_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.use_id(SpeakerSourceMediaPlayer),
cv.Required(CONF_PIPELINE): cv.enum(PIPELINE_ENUM, lower=True),
cv.Required(CONF_DELAY): cv.templatable(cv.positive_time_period_milliseconds),
}
)
@automation.register_action(
"speaker_source.set_playlist_delay",
SetPlaylistDelayAction,
SET_PLAYLIST_DELAY_ACTION_SCHEMA,
synchronous=True,
)
async def set_playlist_delay_action_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
parent = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, parent)
cg.add(var.set_pipeline(config[CONF_PIPELINE]))
template_ = await cg.templatable(config[CONF_DELAY], args, cg.uint32)
cg.add(var.set_delay(template_))
return var
@@ -135,8 +135,12 @@ void SpeakerSourceMediaPlayer::handle_media_state_changed_(uint8_t pipeline, med
PipelineContext &ps = this->pipelines_[pipeline];
if (state == media_source::MediaSourceState::IDLE) {
// Track whether this IDLE was from an orchestrator-initiated stop (e.g., NEXT/PREV/PLAY_URI)
// so we can suppress spurious PLAYLIST_ADVANCE below
bool was_stopping = (ps.stopping_source == source);
// Source went idle - clear stopping flag if this was the source we asked to stop
if (ps.stopping_source == source) {
if (was_stopping) {
ps.stopping_source = nullptr;
}
@@ -152,6 +156,11 @@ void SpeakerSourceMediaPlayer::handle_media_state_changed_(uint8_t pipeline, med
// Finish the speaker to ensure it's ready for the next playback
ps.speaker->finish();
// Only advance the playlist if the track finished naturally (not stopped by the orchestrator)
if (!was_stopping) {
this->queue_command_(MediaPlayerControlCommand::PLAYLIST_ADVANCE, pipeline);
}
}
} else if (state == media_source::MediaSourceState::PLAYING) {
// Source started playing - make it the active source if no one else is active
@@ -197,8 +206,9 @@ size_t SpeakerSourceMediaPlayer::handle_media_output_(uint8_t pipeline, media_so
return 0;
}
// THREAD CONTEXT: Called from main loop (loop)
media_player::MediaPlayerState SpeakerSourceMediaPlayer::get_media_pipeline_state_(
media_source::MediaSource *source) const {
media_source::MediaSource *source, bool playlist_active, media_player::MediaPlayerState old_state) const {
if (source != nullptr) {
switch (source->get_state()) {
case media_source::MediaSourceState::PLAYING:
@@ -214,97 +224,27 @@ media_player::MediaPlayerState SpeakerSourceMediaPlayer::get_media_pipeline_stat
}
}
// No active source. Stay PLAYING during playlist transitions
if (playlist_active && old_state == media_player::MEDIA_PLAYER_STATE_PLAYING) {
return media_player::MEDIA_PLAYER_STATE_PLAYING;
}
return media_player::MEDIA_PLAYER_STATE_IDLE;
}
void SpeakerSourceMediaPlayer::loop() {
// Process queued control commands
MediaPlayerControlCommand control_command;
// Use peek to check command without removing it
if (xQueuePeek(this->media_control_command_queue_, &control_command, 0) == pdTRUE) {
bool command_executed = false;
uint8_t pipeline = control_command.pipeline;
switch (control_command.type) {
case MediaPlayerControlCommand::PLAY_URI: {
command_executed = this->try_execute_play_uri_(*control_command.data.uri, pipeline);
break;
}
case MediaPlayerControlCommand::SEND_COMMAND: {
PipelineContext &ps = this->pipelines_[pipeline];
// Determine target source: prefer active, fall back to last
media_source::MediaSource *target_source = nullptr;
if (ps.active_source != nullptr) {
target_source = ps.active_source;
} else if (ps.last_source != nullptr) {
target_source = ps.last_source;
}
media_player::MediaPlayerCommand player_command = control_command.data.command;
switch (player_command) {
case media_player::MEDIA_PLAYER_COMMAND_TOGGLE: {
media_source::MediaSource *active_source = ps.active_source;
if ((active_source != nullptr) && (active_source->get_state() == media_source::MediaSourceState::PLAYING)) {
if (target_source != nullptr) {
target_source->handle_command(media_source::MediaSourceCommand::PAUSE);
}
} else {
if (target_source != nullptr) {
target_source->handle_command(media_source::MediaSourceCommand::PLAY);
}
}
break;
}
case media_player::MEDIA_PLAYER_COMMAND_PLAY: {
if (target_source != nullptr) {
target_source->handle_command(media_source::MediaSourceCommand::PLAY);
}
break;
}
case media_player::MEDIA_PLAYER_COMMAND_PAUSE: {
if (target_source != nullptr) {
target_source->handle_command(media_source::MediaSourceCommand::PAUSE);
}
break;
}
case media_player::MEDIA_PLAYER_COMMAND_STOP: {
if (target_source != nullptr) {
target_source->handle_command(media_source::MediaSourceCommand::STOP);
}
break;
}
default:
break;
}
command_executed = true;
break;
}
}
// Only remove from queue if successfully executed
if (command_executed) {
xQueueReceive(this->media_control_command_queue_, &control_command, 0);
// Delete the allocated string for PLAY_URI commands
if (control_command.type == MediaPlayerControlCommand::PLAY_URI) {
delete control_command.data.uri;
}
}
}
this->process_control_queue_();
// Update state based on active sources
media_player::MediaPlayerState old_state = this->state;
PipelineContext &media_ps = this->pipelines_[MEDIA_PIPELINE];
this->state = this->get_media_pipeline_state_(media_ps.active_source);
// Check playlist state to detect transitions between items
bool media_playlist_active = (media_ps.playlist_index < media_ps.playlist.size()) ||
(media_ps.repeat_mode != REPEAT_OFF && !media_ps.playlist.empty());
this->state = this->get_media_pipeline_state_(media_ps.active_source, media_playlist_active, old_state);
if (this->state != old_state) {
this->publish_state();
@@ -349,9 +289,9 @@ bool SpeakerSourceMediaPlayer::try_execute_play_uri_(const std::string &uri, uin
// Only send END command once per source - check if we've already asked this source to stop
if (ps.stopping_source != active_source) {
ESP_LOGV(TAG, "Pipeline %u: stopping active source", pipeline);
ps.stopping_source = active_source;
active_source->handle_command(media_source::MediaSourceCommand::STOP);
ps.speaker->stop();
ps.stopping_source = active_source;
}
return false; // Leave in queue, retry next loop
}
@@ -363,9 +303,9 @@ bool SpeakerSourceMediaPlayer::try_execute_play_uri_(const std::string &uri, uin
// Only send STOP command once per source
if (ps.stopping_source != target_source) {
ESP_LOGV(TAG, "Pipeline %u: target source busy, stopping", pipeline);
ps.stopping_source = target_source;
target_source->handle_command(media_source::MediaSourceCommand::STOP);
ps.speaker->stop();
ps.stopping_source = target_source;
}
return false; // Leave in queue, retry next loop
}
@@ -385,6 +325,7 @@ bool SpeakerSourceMediaPlayer::try_execute_play_uri_(const std::string &uri, uin
if (!target_source->play_uri(uri)) {
ESP_LOGE(TAG, "Pipeline %u: Failed to play URI: %s", pipeline, uri.c_str());
ps.pending_source = nullptr;
this->queue_command_(MediaPlayerControlCommand::PLAYLIST_ADVANCE, pipeline);
}
// Reset pending frame counter for this pipeline since we're starting a new source
@@ -393,6 +334,280 @@ bool SpeakerSourceMediaPlayer::try_execute_play_uri_(const std::string &uri, uin
return true; // Remove from queue
}
// THREAD CONTEXT: Called from main loop (process_control_queue_, queue_play_current_, handle_media_state_changed_)
void SpeakerSourceMediaPlayer::queue_command_(MediaPlayerControlCommand::Type type, uint8_t pipeline) {
MediaPlayerControlCommand cmd{};
cmd.type = type;
cmd.pipeline = pipeline;
if (xQueueSend(this->media_control_command_queue_, &cmd, 0) != pdTRUE) {
ESP_LOGE(TAG, "Queue full, command dropped");
}
}
// THREAD CONTEXT: Called from main loop via automation commands (direct)
void SpeakerSourceMediaPlayer::set_playlist_delay_ms(uint8_t pipeline, uint32_t delay_ms) {
if (pipeline < this->pipelines_.size()) {
this->pipelines_[pipeline].playlist_delay_ms = delay_ms;
}
}
// THREAD CONTEXT: Called from main loop (process_control_queue_).
// The timeout callback also runs on the main loop.
void SpeakerSourceMediaPlayer::queue_play_current_(uint8_t pipeline, uint32_t delay_ms) {
if (delay_ms > 0) {
this->set_timeout(PipelineContext::TIMEOUT_IDS[pipeline], delay_ms,
[this, pipeline]() { this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); });
} else {
this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline);
}
}
// THREAD CONTEXT: Called from main loop (loop)
void SpeakerSourceMediaPlayer::process_control_queue_() {
MediaPlayerControlCommand control_command;
// Use peek to check command without removing it
if (xQueuePeek(this->media_control_command_queue_, &control_command, 0) != pdTRUE) {
return;
}
bool command_executed = false;
uint8_t pipeline = control_command.pipeline;
// Get pipeline state
PipelineContext &ps = this->pipelines_[pipeline];
media_source::MediaSource *active_source = ps.active_source;
switch (control_command.type) {
case MediaPlayerControlCommand::PLAY_URI: {
// Always use our local playlist to start playback
this->cancel_timeout(PipelineContext::TIMEOUT_IDS[pipeline]);
ps.playlist.clear();
ps.playlist_index = 0; // Reset index
ps.playlist.push_back(*control_command.data.uri);
// Queue PLAY_CURRENT to initiate playback
this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline);
command_executed = true;
break;
}
case MediaPlayerControlCommand::ENQUEUE_URI: {
// Always add to our local playlist
ps.playlist.push_back(*control_command.data.uri);
// If nothing is playing and no upcoming items are queued, start the new item.
bool nothing_playing =
(active_source == nullptr) || (active_source->get_state() == media_source::MediaSourceState::IDLE);
if (nothing_playing && ps.playlist_index >= ps.playlist.size() - 1) {
ps.playlist_index = ps.playlist.size() - 1; // Point to newly added item
this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline);
}
command_executed = true;
break;
}
case MediaPlayerControlCommand::PLAYLIST_ADVANCE: {
// Internal message: a track finished, advance to next
if (ps.repeat_mode != REPEAT_ONE) {
ps.playlist_index++;
}
// Check if we should continue playback
if (ps.playlist_index < ps.playlist.size()) {
this->queue_play_current_(pipeline, ps.playlist_delay_ms);
} else if (ps.repeat_mode == REPEAT_ALL && !ps.playlist.empty()) {
ps.playlist_index = 0;
this->queue_play_current_(pipeline, ps.playlist_delay_ms);
}
command_executed = true;
break;
}
case MediaPlayerControlCommand::PLAY_CURRENT: {
// Play the item at current playlist index
if (ps.playlist_index < ps.playlist.size()) {
command_executed = this->try_execute_play_uri_(ps.playlist[ps.playlist_index], pipeline);
} else {
command_executed = true; // Index out of bounds or empty playlist
}
break;
}
case MediaPlayerControlCommand::SEND_COMMAND: {
this->handle_player_command_(control_command.data.command, pipeline);
command_executed = true;
break;
}
}
// Only remove from queue if successfully executed
if (command_executed) {
xQueueReceive(this->media_control_command_queue_, &control_command, 0);
// Delete the allocated string for PLAY_URI and ENQUEUE_URI commands
if (control_command.type == MediaPlayerControlCommand::PLAY_URI ||
control_command.type == MediaPlayerControlCommand::ENQUEUE_URI) {
delete control_command.data.uri;
}
}
}
// THREAD CONTEXT: Called from main loop only (via process_control_queue_)
void SpeakerSourceMediaPlayer::handle_player_command_(media_player::MediaPlayerCommand player_command,
uint8_t pipeline) {
PipelineContext &ps = this->pipelines_[pipeline];
media_source::MediaSource *active_source = ps.active_source;
bool has_internal_playlist = (active_source != nullptr) && active_source->has_internal_playlist();
// Determine target source: prefer active, fall back to last
media_source::MediaSource *target_source = nullptr;
if (active_source != nullptr) {
target_source = active_source;
} else if (ps.last_source != nullptr) {
target_source = ps.last_source;
}
switch (player_command) {
case media_player::MEDIA_PLAYER_COMMAND_TOGGLE: {
// Convert TOGGLE to PLAY or PAUSE based on current state
if ((active_source != nullptr) && (active_source->get_state() == media_source::MediaSourceState::PLAYING)) {
if (target_source != nullptr) {
target_source->handle_command(media_source::MediaSourceCommand::PAUSE);
}
} else if (!has_internal_playlist && active_source == nullptr && !ps.playlist.empty()) {
bool last_has_internal_playlist = (ps.last_source != nullptr) && ps.last_source->has_internal_playlist();
if (last_has_internal_playlist) {
ps.last_source->handle_command(media_source::MediaSourceCommand::PLAY);
} else {
if (ps.playlist_index >= ps.playlist.size()) {
ps.playlist_index = 0;
}
this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline);
}
} else {
if (target_source != nullptr) {
target_source->handle_command(media_source::MediaSourceCommand::PLAY);
}
}
break;
}
case media_player::MEDIA_PLAYER_COMMAND_PLAY: {
if (!has_internal_playlist && active_source == nullptr && !ps.playlist.empty()) {
bool last_has_internal_playlist = (ps.last_source != nullptr) && ps.last_source->has_internal_playlist();
if (last_has_internal_playlist) {
ps.last_source->handle_command(media_source::MediaSourceCommand::PLAY);
} else {
if (ps.playlist_index >= ps.playlist.size()) {
ps.playlist_index = 0;
}
this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline);
}
} else if (target_source != nullptr) {
target_source->handle_command(media_source::MediaSourceCommand::PLAY);
}
break;
}
case media_player::MEDIA_PLAYER_COMMAND_PAUSE: {
if (target_source != nullptr) {
target_source->handle_command(media_source::MediaSourceCommand::PAUSE);
}
break;
}
case media_player::MEDIA_PLAYER_COMMAND_STOP: {
if (!has_internal_playlist) {
this->cancel_timeout(PipelineContext::TIMEOUT_IDS[pipeline]);
ps.playlist.clear();
ps.playlist_index = 0;
}
if (target_source != nullptr) {
target_source->handle_command(media_source::MediaSourceCommand::STOP);
}
break;
}
case media_player::MEDIA_PLAYER_COMMAND_NEXT: {
if (!has_internal_playlist) {
this->cancel_timeout(PipelineContext::TIMEOUT_IDS[pipeline]);
if (ps.playlist_index + 1 < ps.playlist.size()) {
ps.playlist_index++;
this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline);
} else if (ps.repeat_mode == REPEAT_ALL && !ps.playlist.empty()) {
ps.playlist_index = 0;
this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline);
}
} else if (target_source != nullptr) {
target_source->handle_command(media_source::MediaSourceCommand::NEXT);
}
break;
}
case media_player::MEDIA_PLAYER_COMMAND_PREVIOUS: {
if (!has_internal_playlist) {
this->cancel_timeout(PipelineContext::TIMEOUT_IDS[pipeline]);
if (ps.playlist_index > 0) {
ps.playlist_index--;
this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline);
} else if (ps.repeat_mode == REPEAT_ALL && !ps.playlist.empty()) {
ps.playlist_index = ps.playlist.size() - 1;
this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline);
}
} else if (target_source != nullptr) {
target_source->handle_command(media_source::MediaSourceCommand::PREVIOUS);
}
break;
}
case media_player::MEDIA_PLAYER_COMMAND_REPEAT_ONE:
if (!has_internal_playlist) {
ps.repeat_mode = REPEAT_ONE;
} else if (target_source != nullptr) {
target_source->handle_command(media_source::MediaSourceCommand::REPEAT_ONE);
}
break;
case media_player::MEDIA_PLAYER_COMMAND_REPEAT_OFF:
if (!has_internal_playlist) {
ps.repeat_mode = REPEAT_OFF;
} else if (target_source != nullptr) {
target_source->handle_command(media_source::MediaSourceCommand::REPEAT_OFF);
}
break;
case media_player::MEDIA_PLAYER_COMMAND_REPEAT_ALL:
if (!has_internal_playlist) {
ps.repeat_mode = REPEAT_ALL;
} else if (target_source != nullptr) {
target_source->handle_command(media_source::MediaSourceCommand::REPEAT_ALL);
}
break;
case media_player::MEDIA_PLAYER_COMMAND_CLEAR_PLAYLIST: {
if (!has_internal_playlist) {
this->cancel_timeout(PipelineContext::TIMEOUT_IDS[pipeline]);
if (ps.playlist_index < ps.playlist.size()) {
ps.playlist[0] = std::move(ps.playlist[ps.playlist_index]);
ps.playlist.resize(1);
ps.playlist_index = 0;
} else {
ps.playlist.clear();
ps.playlist_index = 0;
}
} else if (target_source != nullptr) {
target_source->handle_command(media_source::MediaSourceCommand::CLEAR_PLAYLIST);
}
break;
}
default:
// TURN_ON, TURN_OFF, ENQUEUE (handled separately with URL), SHUFFLE/UNSHUFFLE (PR3) are no-ops
break;
}
}
// THREAD CONTEXT: Called from main loop only. Entry points:
// - HA/automation commands (direct)
// - handle_play_uri_request_() via make_call().perform() (deferred from source tasks)
@@ -406,10 +621,17 @@ void SpeakerSourceMediaPlayer::control(const media_player::MediaPlayerCall &call
auto media_url = call.get_media_url();
if (media_url.has_value()) {
control_command.type = MediaPlayerControlCommand::PLAY_URI;
auto command = call.get_command();
bool enqueue = command.has_value() && command.value() == media_player::MEDIA_PLAYER_COMMAND_ENQUEUE;
if (enqueue) {
control_command.type = MediaPlayerControlCommand::ENQUEUE_URI;
} else {
control_command.type = MediaPlayerControlCommand::PLAY_URI;
}
// Heap allocation is unavoidable: URIs from Home Assistant are arbitrary-length (media URLs with tokens
// can easily exceed 500 bytes). Deleted after the command is consumed. FreeRTOS queues require items to be
// copyable, so we store a pointer to the string in the queue rather than the string itself.
// can easily exceed 500 bytes). Deleted in process_control_queue_() after the command is consumed. FreeRTOS queues
// require items to be copyable, so we store a pointer to the string in the queue rather than the string itself.
control_command.data.uri = new std::string(media_url.value());
if (xQueueSend(this->media_control_command_queue_, &control_command, 0) != pdTRUE) {
delete control_command.data.uri;
@@ -454,6 +676,9 @@ void SpeakerSourceMediaPlayer::control(const media_player::MediaPlayerCall &call
}
media_player::MediaPlayerTraits SpeakerSourceMediaPlayer::get_traits() {
// This media player supports more traits like playlists, repeat, and shuffle, but the ESPHome API currently (March
// 2026) doesn't support those commands, so we only report pause support for now since that's used by the frontend and
// supported by our player.
auto traits = media_player::MediaPlayerTraits();
traits.set_supports_pause(true);
@@ -29,7 +29,8 @@ namespace esphome::speaker_source {
// - Main loop task: setup(), loop(), dump_config(), handle_media_state_changed_(),
// handle_volume_request_(), handle_mute_request_(), handle_play_uri_request_(),
// set_volume_(), set_mute_state_(), control(), get_media_pipeline_state_(),
// find_source_for_uri_(), try_execute_play_uri_(), save_volume_restore_state_()
// find_source_for_uri_(), try_execute_play_uri_(), save_volume_restore_state_(),
// process_control_queue_(), handle_player_command_(), queue_command_(), queue_play_current_()
//
// - Media source task(s): handle_media_output_() via SourceBinding::write_audio().
// Called from each source's decode task thread when streaming audio data.
@@ -49,13 +50,19 @@ namespace esphome::speaker_source {
// - defer(): SourceBinding::request_volume/request_mute/request_play_uri -> main loop
// - Atomic fields (active_source, pending_frames): shared between all three thread contexts
//
// Non-atomic pipeline fields (last_source, stopping_source, pending_source) are only accessed
// from the main loop thread.
// Non-atomic pipeline fields (last_source, stopping_source, pending_source, playlist,
// playlist_index, repeat_mode) are only accessed from the main loop thread.
enum Pipeline : uint8_t {
MEDIA_PIPELINE = 0,
};
enum RepeatMode : uint8_t {
REPEAT_OFF = 0,
REPEAT_ONE = 1,
REPEAT_ALL = 2,
};
// Forward declaration
class SpeakerSourceMediaPlayer;
@@ -79,6 +86,9 @@ struct SourceBinding : public media_source::MediaSourceListener {
};
struct PipelineContext {
/// @brief Timeout IDs for playlist delay, indexed by Pipeline enum
static constexpr const char *const TIMEOUT_IDS[] = {"next_media"};
speaker::Speaker *speaker{nullptr};
optional<media_player::MediaPlayerSupportedFormat> format;
@@ -92,6 +102,14 @@ struct PipelineContext {
// Uses std::vector because the count varies across instances (multiple speaker_source media players may exist).
std::vector<std::unique_ptr<SourceBinding>> sources;
// Dynamic allocation is unavoidable here: URIs from Home Assistant are arbitrary-length strings
// (media URLs with tokens can easily exceed 500 bytes), and playlist size is unbounded.
// Pre-allocating fixed buffers would waste significant RAM when idle without covering worst cases.
std::vector<std::string> playlist;
size_t playlist_index{0};
RepeatMode repeat_mode{REPEAT_OFF};
uint32_t playlist_delay_ms{0};
// Track frames sent to speaker to correlate with playback callbacks.
// Atomic because it is written from the main loop/source tasks and read/decremented from the speaker playback
// callback.
@@ -103,14 +121,17 @@ struct PipelineContext {
struct MediaPlayerControlCommand {
enum Type : uint8_t {
PLAY_URI, // Find a source that can handle this URI and play it
SEND_COMMAND, // Send command to active source
PLAY_URI, // Clear playlist, reset index, add URI, queue PLAY_CURRENT
ENQUEUE_URI, // Add URI to playlist, queue PLAY_CURRENT if idle
PLAYLIST_ADVANCE, // Advance index (or wrap for repeat_all), queue PLAY_CURRENT if more items
PLAY_CURRENT, // Play item at current playlist index (can retry if speaker not ready)
SEND_COMMAND, // Send command to active source
};
Type type;
uint8_t pipeline;
union {
std::string *uri; // Owned pointer, must delete after xQueueReceive (for PLAY_URI)
std::string *uri; // Owned pointer, must delete after xQueueReceive (for PLAY_URI and ENQUEUE_URI)
media_player::MediaPlayerCommand command;
} data;
};
@@ -154,6 +175,8 @@ class SpeakerSourceMediaPlayer : public Component, public media_player::MediaPla
Trigger<> *get_unmute_trigger() { return &this->unmute_trigger_; }
Trigger<float> *get_volume_trigger() { return &this->volume_trigger_; }
void set_playlist_delay_ms(uint8_t pipeline, uint32_t delay_ms);
protected:
// Callbacks from source bindings (pipeline index is captured at binding creation time)
size_t handle_media_output_(uint8_t pipeline, media_source::MediaSource *source, const uint8_t *data, size_t length,
@@ -183,11 +206,20 @@ class SpeakerSourceMediaPlayer : public Component, public media_player::MediaPla
/// @brief Determine media player state from the media pipeline's active source
/// @param media_source Active source for the media pipeline (may be nullptr)
/// @param playlist_active Whether the media pipeline's playlist is in progress
/// @param old_state Previous media player state (used for transition smoothing)
/// @return The appropriate MediaPlayerState
media_player::MediaPlayerState get_media_pipeline_state_(media_source::MediaSource *media_source) const;
media_player::MediaPlayerState get_media_pipeline_state_(media_source::MediaSource *media_source,
bool playlist_active,
media_player::MediaPlayerState old_state) const;
void process_control_queue_();
void handle_player_command_(media_player::MediaPlayerCommand player_command, uint8_t pipeline);
bool try_execute_play_uri_(const std::string &uri, uint8_t pipeline);
media_source::MediaSource *find_source_for_uri_(const std::string &uri, uint8_t pipeline);
void queue_command_(MediaPlayerControlCommand::Type type, uint8_t pipeline);
void queue_play_current_(uint8_t pipeline, uint32_t delay_ms = 0);
QueueHandle_t media_control_command_queue_;
// Pipeline context for media pipeline. See THREADING MODEL at top of namespace for access rules.
@@ -41,3 +41,8 @@ media_player:
on_unmute:
- media_player.play:
id: media_player_id
on_volume:
- speaker_source.set_playlist_delay:
id: media_player_id
pipeline: media
delay: 500ms