[speaker_source] Add shuffle support (#14653)

This commit is contained in:
Kevin Ahrendt
2026-03-11 13:11:00 -05:00
committed by GitHub
parent 3d4ebe74ce
commit b27165a842
3 changed files with 100 additions and 7 deletions
@@ -5,6 +5,8 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <algorithm>
namespace esphome::speaker_source {
static constexpr uint32_t MEDIA_CONTROLS_QUEUE_LENGTH = 20;
@@ -383,7 +385,8 @@ void SpeakerSourceMediaPlayer::process_control_queue_() {
// 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.shuffle_indices.clear(); // Clear shuffle when starting fresh playlist
ps.playlist_index = 0; // Reset index
ps.playlist.push_back(*control_command.data.uri);
// Queue PLAY_CURRENT to initiate playback
@@ -396,6 +399,11 @@ void SpeakerSourceMediaPlayer::process_control_queue_() {
// Always add to our local playlist
ps.playlist.push_back(*control_command.data.uri);
// If shuffle is active, add the new item to the end of the shuffle order
if (!ps.shuffle_indices.empty()) {
ps.shuffle_indices.push_back(ps.playlist.size() - 1);
}
// 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);
@@ -425,9 +433,10 @@ void SpeakerSourceMediaPlayer::process_control_queue_() {
}
case MediaPlayerControlCommand::PLAY_CURRENT: {
// Play the item at current playlist index
// Play the item at current playlist index (mapped through shuffle if active)
if (ps.playlist_index < ps.playlist.size()) {
command_executed = this->try_execute_play_uri_(ps.playlist[ps.playlist_index], pipeline);
size_t actual_position = this->get_playlist_position_(pipeline);
command_executed = this->try_execute_play_uri_(ps.playlist[actual_position], pipeline);
} else {
command_executed = true; // Index out of bounds or empty playlist
}
@@ -521,6 +530,7 @@ void SpeakerSourceMediaPlayer::handle_player_command_(media_player::MediaPlayerC
if (!has_internal_playlist) {
this->cancel_timeout(PipelineContext::TIMEOUT_IDS[pipeline]);
ps.playlist.clear();
ps.shuffle_indices.clear();
ps.playlist_index = 0;
}
if (target_source != nullptr) {
@@ -589,21 +599,39 @@ void SpeakerSourceMediaPlayer::handle_player_command_(media_player::MediaPlayerC
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]);
size_t actual_position = this->get_playlist_position_(pipeline);
ps.playlist[0] = std::move(ps.playlist[actual_position]);
ps.playlist.resize(1);
ps.playlist_index = 0;
} else {
ps.playlist.clear();
ps.playlist_index = 0;
}
ps.shuffle_indices.clear();
} else if (target_source != nullptr) {
target_source->handle_command(media_source::MediaSourceCommand::CLEAR_PLAYLIST);
}
break;
}
case media_player::MEDIA_PLAYER_COMMAND_SHUFFLE:
if (!has_internal_playlist) {
this->shuffle_playlist_(pipeline);
} else if (target_source != nullptr) {
target_source->handle_command(media_source::MediaSourceCommand::SHUFFLE);
}
break;
case media_player::MEDIA_PLAYER_COMMAND_UNSHUFFLE:
if (!has_internal_playlist) {
this->unshuffle_playlist_(pipeline);
} else if (target_source != nullptr) {
target_source->handle_command(media_source::MediaSourceCommand::UNSHUFFLE);
}
break;
default:
// TURN_ON, TURN_OFF, ENQUEUE (handled separately with URL), SHUFFLE/UNSHUFFLE (PR3) are no-ops
// TURN_ON, TURN_OFF, ENQUEUE (handled separately with URL) are no-ops
break;
}
}
@@ -766,6 +794,58 @@ void SpeakerSourceMediaPlayer::set_volume_(float volume, bool publish) {
this->defer([this, volume]() { this->volume_trigger_.trigger(volume); });
}
size_t SpeakerSourceMediaPlayer::get_playlist_position_(uint8_t pipeline) const {
const PipelineContext &ps = this->pipelines_[pipeline];
if (ps.shuffle_indices.empty() || ps.playlist_index >= ps.shuffle_indices.size()) {
return ps.playlist_index;
}
return ps.shuffle_indices[ps.playlist_index];
}
void SpeakerSourceMediaPlayer::shuffle_playlist_(uint8_t pipeline) {
PipelineContext &ps = this->pipelines_[pipeline];
if (ps.playlist.size() <= 1) {
ps.shuffle_indices.clear();
return;
}
// Capture current actual position BEFORE modifying shuffle_indices
size_t current_actual = this->get_playlist_position_(pipeline);
// Build indices vector
ps.shuffle_indices.resize(ps.playlist.size());
for (size_t i = 0; i < ps.playlist.size(); i++) {
ps.shuffle_indices[i] = i;
}
// Fisher-Yates shuffle using ESPHome's random helper
for (size_t i = ps.shuffle_indices.size() - 1; i > 0; i--) {
size_t j = random_uint32() % (i + 1);
std::swap(ps.shuffle_indices[i], ps.shuffle_indices[j]);
}
// Move current track to current position (so playback continues seamlessly)
if (ps.playlist_index < ps.shuffle_indices.size()) {
for (size_t i = 0; i < ps.shuffle_indices.size(); i++) {
if (ps.shuffle_indices[i] == current_actual) {
std::swap(ps.shuffle_indices[i], ps.shuffle_indices[ps.playlist_index]);
break;
}
}
}
}
void SpeakerSourceMediaPlayer::unshuffle_playlist_(uint8_t pipeline) {
PipelineContext &ps = this->pipelines_[pipeline];
if (!ps.shuffle_indices.empty() && ps.playlist_index < ps.shuffle_indices.size()) {
ps.playlist_index = ps.shuffle_indices[ps.playlist_index];
}
ps.shuffle_indices.clear();
}
} // namespace esphome::speaker_source
#endif // USE_ESP32
@@ -110,6 +110,10 @@ struct PipelineContext {
RepeatMode repeat_mode{REPEAT_OFF};
uint32_t playlist_delay_ms{0};
// When non-empty, playlist_index indexes into these vectors
// which contain the actual playlist indices in shuffled order
std::vector<size_t> shuffle_indices;
// 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.
@@ -220,6 +224,15 @@ class SpeakerSourceMediaPlayer : public Component, public media_player::MediaPla
void queue_command_(MediaPlayerControlCommand::Type type, uint8_t pipeline);
void queue_play_current_(uint8_t pipeline, uint32_t delay_ms = 0);
/// @brief Maps playlist_index through shuffle indices if shuffle is active
size_t get_playlist_position_(uint8_t pipeline) const;
/// @brief Generates shuffled indices for the playlist, keeping current track at current position
void shuffle_playlist_(uint8_t pipeline);
/// @brief Clears shuffle indices and adjusts playlist_index to maintain current track
void unshuffle_playlist_(uint8_t pipeline);
QueueHandle_t media_control_command_queue_;
// Pipeline context for media pipeline. See THREADING MODEL at top of namespace for access rules.
+2 -2
View File
@@ -36,10 +36,10 @@ media_player:
sources:
- audio_file_source
on_mute:
- media_player.pause:
- media_player.shuffle:
id: media_player_id
on_unmute:
- media_player.play:
- media_player.unshuffle:
id: media_player_id
on_volume:
- speaker_source.set_playlist_delay: