ESPHome 2026.11.0-dev
Loading...
Searching...
No Matches
speaker_source_media_player.cpp
Go to the documentation of this file.
2
3#ifdef USE_ESP32
4
6#include "esphome/core/log.h"
7
8#include <algorithm>
9
11
12static constexpr uint32_t MEDIA_CONTROLS_QUEUE_LENGTH = 20;
13
14ESPHOME_LOG_TAG(TAG, "speaker_source_media_player");
15
16// SourceBinding method implementations (defined here because SpeakerSourceMediaPlayer is forward-declared in the
17// header)
18
19// THREAD CONTEXT: Called from media source decode task thread
20size_t SourceBinding::write_audio(const uint8_t *data, size_t length, uint32_t timeout_ms,
21 const audio::AudioStreamInfo &stream_info) {
22 return this->player->handle_media_output_(this->pipeline, this->source, data, length, timeout_ms, stream_info);
23}
24
25// THREAD CONTEXT: Called from main loop (media source's loop() calls set_state_ which calls report_state)
29
30// THREAD CONTEXT: Called from media source task thread; uses defer() to marshal to main loop
32 this->player->defer([this, volume]() { this->player->handle_volume_request_(volume); });
33}
34
35// THREAD CONTEXT: Called from media source task thread; uses defer() to marshal to main loop
36void SourceBinding::request_mute(bool is_muted) {
37 this->player->defer([this, is_muted]() { this->player->handle_mute_request_(is_muted); });
38}
39
40// THREAD CONTEXT: Called from media source task thread; uses defer() to marshal to main loop
41void SourceBinding::request_play_uri(const std::string &uri) {
42 this->player->defer([this, uri]() { this->player->handle_play_uri_request_(this->pipeline, uri); });
43}
44
45// THREAD CONTEXT: Called during code generation setup (main loop)
47 auto &binding =
48 this->pipelines_[pipeline].sources.emplace_back(std::make_unique<SourceBinding>(this, media_source, pipeline));
49 media_source->set_listener(binding.get());
50}
51
53 ESP_LOGCONFIG(TAG,
54 "Speaker Source Media Player:\n"
55 " Volume Increment: %.2f\n"
56 " Volume Min: %.2f\n"
57 " Volume Max: %.2f",
58 this->volume_increment_, this->volume_min_, this->volume_max_);
59}
60
63
64 this->media_control_command_queue_ = xQueueCreate(MEDIA_CONTROLS_QUEUE_LENGTH, sizeof(MediaPlayerControlCommand));
65
67
68 VolumeRestoreState volume_restore_state;
69 if (this->pref_.load(&volume_restore_state)) {
70 this->set_volume_(volume_restore_state.volume);
71 this->set_mute_state_(volume_restore_state.is_muted);
72 } else {
73 this->set_volume_(this->volume_initial_);
74 this->set_mute_state_(false);
75 }
76
77 // Register callbacks to receive playback notifications from speakers
78 for (size_t i = 0; i < this->pipelines_.size(); i++) {
79 if (this->pipelines_[i].is_configured()) {
80 this->pipelines_[i].speaker->add_audio_output_callback([this, i](uint32_t frames, int64_t timestamp) {
82 });
83 }
84 }
85}
86
87// THREAD CONTEXT: Called from the speaker's playback callback task (not main loop)
89 PipelineContext &ps = this->pipelines_[pipeline];
90
91 // Load once so the null check and use below are consistent
92 media_source::MediaSource *active_source = ps.active_source.load(std::memory_order_relaxed);
93 if (active_source == nullptr) {
94 return;
95 }
96
97 // CAS loop to safely subtract frames without underflow. If pending_frames is reset to 0 (new source
98 // starting) between the load and the subtract, compare_exchange_weak will fail and reload the current value.
99 uint32_t current = ps.pending_frames.load(std::memory_order_relaxed);
100 uint32_t source_frames;
101 do {
102 source_frames = std::min(frames, current);
103 } while (source_frames > 0 &&
104 !ps.pending_frames.compare_exchange_weak(current, current - source_frames, std::memory_order_relaxed));
105
106 if (source_frames > 0) {
107 // Notify the source about the played audio
108 active_source->notify_audio_played(source_frames, timestamp);
109 }
110}
111
112// THREAD CONTEXT: Called from main loop via defer()
114 // Update the media player's volume
115 this->set_volume_(volume);
116 this->publish_state();
117}
118
119// THREAD CONTEXT: Called from main loop via defer()
121 // Update the media player's mute state
122 this->set_mute_state_(is_muted);
123 this->publish_state();
124}
125
126// THREAD CONTEXT: Called from main loop via defer()
127void SpeakerSourceMediaPlayer::handle_play_uri_request_(uint8_t pipeline, const std::string &uri) {
128 // Smart source is requesting the player to play a different URI
129 auto call = this->make_call();
130 call.set_media_url(uri);
131 call.set_announcement(pipeline == ANNOUNCEMENT_PIPELINE);
132 call.perform();
133}
134
135// THREAD CONTEXT: Called from main loop (media source's loop() calls set_state_ which calls report_state)
138 PipelineContext &ps = this->pipelines_[pipeline];
139
141 // Track whether this IDLE was from an orchestrator-initiated stop (e.g., NEXT/PREV/PLAY_URI)
142 // so we can suppress spurious PLAYLIST_ADVANCE below
143 bool was_stopping = (ps.stopping_source == source);
144
145 // Source went idle - clear stopping flag if this was the source we asked to stop
146 if (was_stopping) {
147 ps.stopping_source = nullptr;
148 }
149
150 // Clear pending flag if this was the source we asked to play
151 if (ps.pending_source == source) {
152 ps.pending_source = nullptr;
153 }
154
155 // Source went idle - clear it if it's the active source
156 if (ps.active_source == source) {
158 ps.active_source = nullptr;
159
160 // Finish the speaker to ensure it's ready for the next playback
161 ps.speaker->finish();
162
163 // Only advance the playlist if the track finished naturally (not stopped by the orchestrator)
164 if (!was_stopping) {
166 }
167 }
169 // Source started playing - make it the active source if no one else is active
170 if (ps.active_source == nullptr) {
171 ps.active_source = source;
172 ps.last_source = nullptr;
173
174 // Clear pending flag now that the source is active
175 if (ps.pending_source == source) {
176 ps.pending_source = nullptr;
177 }
178 }
179 }
180}
181
182// THREAD CONTEXT: Called from media source decode task thread (not main loop).
183// Reads ps.active_source (atomic), writes ps.pending_frames (atomic), and calls
184// ps.speaker methods (speaker pointer is immutable after setup).
186 const uint8_t *data, size_t length, uint32_t timeout_ms,
187 const audio::AudioStreamInfo &stream_info) {
188 PipelineContext &ps = this->pipelines_[pipeline];
189
190 // Single read; the if-body only uses ps.speaker (immutable after setup) and the source parameter.
191 if (ps.active_source == source) {
192 // This source is active - play the audio
193 if (ps.speaker->get_audio_stream_info() != stream_info) {
194 // Setup the speaker to play this stream
195 ps.speaker->set_audio_stream_info(stream_info);
196 vTaskDelay(pdMS_TO_TICKS(timeout_ms));
197 return 0;
198 }
199 // Reserve frames before the speaker can play them and report their progress,
200 // then release the portion it did not accept.
201 const uint32_t reserved_frames = stream_info.bytes_to_frames(length);
202 ps.pending_frames.fetch_add(reserved_frames, std::memory_order_relaxed);
203 size_t bytes_written = ps.speaker->play(data, length, pdMS_TO_TICKS(timeout_ms));
204 const uint32_t unused_frames = reserved_frames - stream_info.bytes_to_frames(bytes_written);
205 if (unused_frames > 0) {
206 // The main loop may reset pending_frames while play() is blocked.
207 // Clamp the release so that a concurrent reset cannot cause underflow.
208 uint32_t pending = ps.pending_frames.load(std::memory_order_relaxed);
209 while (!ps.pending_frames.compare_exchange_weak(pending, pending - std::min(pending, unused_frames),
210 std::memory_order_relaxed)) {
211 }
212 }
213 return bytes_written;
214 }
215
216 // Not the active source - wait for state callback to set us as active when we transition to PLAYING
217 vTaskDelay(pdMS_TO_TICKS(timeout_ms));
218 return 0;
219}
220
221// THREAD CONTEXT: Called from main loop (loop)
223 media_source::MediaSource *source, bool playlist_active, media_player::MediaPlayerState old_state) const {
224 if (source != nullptr) {
225 switch (source->get_state()) {
231 ESP_LOGE(TAG, "Media source error");
234 default:
236 }
237 }
238
239 // No active source. Stay PLAYING during playlist transitions
240 if (playlist_active && old_state == media_player::MEDIA_PLAYER_STATE_PLAYING) {
242 }
244}
245
247 // Process queued control commands
249
250 // Update state based on active sources - announcement pipeline takes priority
251 media_player::MediaPlayerState old_state = this->state;
252
254 PipelineContext &media_ps = this->pipelines_[MEDIA_PIPELINE];
255
256 // Check playlist state to detect transitions between items
257 bool announcement_playlist_active = (ann_ps.playlist_index < ann_ps.playlist.size()) ||
258 (ann_ps.repeat_mode != REPEAT_OFF && !ann_ps.playlist.empty());
259 bool media_playlist_active = (media_ps.playlist_index < media_ps.playlist.size()) ||
260 (media_ps.repeat_mode != REPEAT_OFF && !media_ps.playlist.empty());
261
262 // Check announcement pipeline first
263 media_source::MediaSource *announcement_source = ann_ps.active_source;
264 if (announcement_source != nullptr) {
265 media_source::MediaSourceState announcement_state = announcement_source->get_state();
266 if (announcement_state != media_source::MediaSourceState::IDLE) {
267 // Announcement is active - announcements take priority and never report PAUSED
268 switch (announcement_state) {
270 case media_source::MediaSourceState::PAUSED: // Treat paused announcements as announcing
272 break;
274 ESP_LOGE(TAG, "Announcement source error");
275 // Fall through to media pipeline state
276 this->state = this->get_source_state_(media_ps.active_source, media_playlist_active, old_state);
277 break;
278 default:
279 break;
280 }
281 } else {
282 // Announcement source is idle, fall through to media pipeline
283 this->state = this->get_source_state_(media_ps.active_source, media_playlist_active, old_state);
284 }
285 } else if (announcement_playlist_active && old_state == media_player::MEDIA_PLAYER_STATE_ANNOUNCING) {
287 } else {
288 // No active announcement, check media pipeline
289 this->state = this->get_source_state_(media_ps.active_source, media_playlist_active, old_state);
290 }
291
292 if (this->state != old_state) {
293 this->publish_state();
294 ESP_LOGD(TAG, "State changed to %s", media_player::media_player_state_to_string(this->state));
295 }
296}
297
299 PipelineContext &ps = this->pipelines_[pipeline];
300 media_source::MediaSource *first_match = nullptr;
301 for (auto &binding : ps.sources) {
302 if (binding->source->can_handle(uri)) {
303 // Prefer an idle source; otherwise remember the first match (will be stopped by try_execute_play_uri_)
304 if (binding->source->get_state() == media_source::MediaSourceState::IDLE) {
305 return binding->source;
306 }
307 if (first_match == nullptr) {
308 first_match = binding->source;
309 }
310 }
311 }
312 return first_match;
313}
314
315bool SpeakerSourceMediaPlayer::try_execute_play_uri_(const std::string &uri, uint8_t pipeline) {
316 // Find target source
317 media_source::MediaSource *target_source = this->find_source_for_uri_(uri, pipeline);
318 if (target_source == nullptr) {
319 ESP_LOGW(TAG, "No source for URI");
320 ESP_LOGV(TAG, "URI: %s", uri.c_str());
321 return true; // Remove from queue (unrecoverable)
322 }
323
324 PipelineContext &ps = this->pipelines_[pipeline];
325
326 media_source::MediaSource *active_source = ps.active_source;
327
328 // If active source exists and is not IDLE, stop it and wait
329 if (active_source != nullptr) {
330 media_source::MediaSourceState active_state = active_source->get_state();
331 if (active_state != media_source::MediaSourceState::IDLE) {
332 // Only send END command once per source - check if we've already asked this source to stop
333 if (ps.stopping_source != active_source) {
334 ESP_LOGV(TAG, "Pipeline %u: stopping active source", pipeline);
335 ps.stopping_source = active_source;
337 ps.speaker->stop();
338 }
339 return false; // Leave in queue, retry next loop
340 }
341 }
342
343 // Also check target source directly - handles case where source errored before PLAYING state
344 media_source::MediaSourceState target_state = target_source->get_state();
345 if (target_state != media_source::MediaSourceState::IDLE) {
346 // Only send STOP command once per source
347 if (ps.stopping_source != target_source) {
348 ESP_LOGV(TAG, "Pipeline %u: target source busy, stopping", pipeline);
349 ps.stopping_source = target_source;
351 ps.speaker->stop();
352 }
353 return false; // Leave in queue, retry next loop
354 }
355
356 // Clear stopping flag since we're past the stopping phase
357 ps.stopping_source = nullptr;
358
359 // Check if speaker is ready
360 if (!ps.speaker->is_stopped()) {
361 return false; // Speaker not ready yet, retry later
362 }
363
364 // Set pending source so handle_media_state_changed_ can recognize it when the source transitions to PLAYING
365 ps.pending_source = target_source;
366
367 // Speaker is ready, try to play
368 if (!target_source->play_uri(uri)) {
369 ESP_LOGE(TAG, "Pipeline %u: Failed to play URI: %s", pipeline, uri.c_str());
370 ps.pending_source = nullptr;
372 }
373
374 // Reset pending frame counter for this pipeline since we're starting a new source
375 ps.pending_frames.store(0, std::memory_order_relaxed);
376
377 return true; // Remove from queue
378}
379
380// THREAD CONTEXT: Called from main loop (process_control_queue_, queue_play_current_, handle_media_state_changed_)
383 cmd.type = type;
384 cmd.pipeline = pipeline;
385 if (xQueueSend(this->media_control_command_queue_, &cmd, 0) != pdTRUE) {
386 ESP_LOGE(TAG, "Queue full, command dropped");
387 }
388}
389
390// THREAD CONTEXT: Called from main loop via automation commands (direct)
392 if (pipeline < this->pipelines_.size()) {
393 this->pipelines_[pipeline].playlist_delay_ms = delay_ms;
394 }
395}
396
397// THREAD CONTEXT: Called from main loop (process_control_queue_).
398// The timeout callback also runs on the main loop.
400 if (delay_ms > 0) {
401 this->set_timeout(PIPELINE_TIMEOUT_IDS[pipeline], delay_ms,
402 [this, pipeline]() { this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); });
403 } else {
405 }
406}
407
408// THREAD CONTEXT: Called from main loop (loop)
410 MediaPlayerControlCommand control_command{};
411
412 // Use peek to check command without removing it
413 if (xQueuePeek(this->media_control_command_queue_, &control_command, 0) != pdTRUE) {
414 return;
415 }
416
417 bool command_executed = false;
418 uint8_t pipeline = control_command.pipeline;
419
420 // Get pipeline state
421 PipelineContext &ps = this->pipelines_[pipeline];
422 media_source::MediaSource *active_source = ps.active_source;
423
424 switch (control_command.type) {
426 // Always use our local playlist to start playback
427 this->cancel_timeout(PIPELINE_TIMEOUT_IDS[pipeline]);
428 ps.playlist.clear();
429 ps.shuffle_indices.clear(); // Clear shuffle when starting fresh playlist
430 ps.playlist_index = 0; // Reset index
431 ps.playlist.push_back(*control_command.data.uri);
432
433 // Queue PLAY_CURRENT to initiate playback
435 command_executed = true;
436 break;
437 }
438
440 // Always add to our local playlist
441 ps.playlist.push_back(*control_command.data.uri);
442
443 // If shuffle is active, add the new item to the end of the shuffle order
444 if (!ps.shuffle_indices.empty()) {
445 ps.shuffle_indices.push_back(ps.playlist.size() - 1);
446 }
447
448 // If nothing is playing and no upcoming items are queued, start the new item.
449 bool nothing_playing =
450 (active_source == nullptr) || (active_source->get_state() == media_source::MediaSourceState::IDLE);
451 if (nothing_playing && ps.playlist_index >= ps.playlist.size() - 1) {
452 ps.playlist_index = ps.playlist.size() - 1; // Point to newly added item
454 }
455 command_executed = true;
456 break;
457 }
458
460 // Internal message: a track finished, advance to next
461 if (ps.repeat_mode != REPEAT_ONE) {
462 ps.playlist_index++;
463 }
464
465 // Check if we should continue playback
466 if (ps.playlist_index < ps.playlist.size()) {
467 this->queue_play_current_(pipeline, ps.playlist_delay_ms);
468 } else if (ps.repeat_mode == REPEAT_ALL && !ps.playlist.empty()) {
469 if (!ps.shuffle_indices.empty()) {
470 this->shuffle_playlist_(pipeline);
471 }
472 ps.playlist_index = 0;
473 this->queue_play_current_(pipeline, ps.playlist_delay_ms);
474 }
475 command_executed = true;
476 break;
477 }
478
480 // Play the item at current playlist index (mapped through shuffle if active)
481 if (ps.playlist_index < ps.playlist.size()) {
482 size_t actual_position = this->get_playlist_position_(pipeline);
483 command_executed = this->try_execute_play_uri_(ps.playlist[actual_position], pipeline);
484 } else {
485 command_executed = true; // Index out of bounds or empty playlist
486 }
487 break;
488 }
489
491 this->handle_player_command_(control_command.data.command, pipeline);
492 command_executed = true;
493 break;
494 }
495 }
496
497 // Only remove from queue if successfully executed
498 if (command_executed) {
499 xQueueReceive(this->media_control_command_queue_, &control_command, 0);
500
501 // Delete the allocated string for PLAY_URI and ENQUEUE_URI commands
502 if (control_command.type == MediaPlayerControlCommand::PLAY_URI ||
503 control_command.type == MediaPlayerControlCommand::ENQUEUE_URI) {
504 delete control_command.data.uri;
505 }
506 }
507}
508
509// THREAD CONTEXT: Called from main loop only (via process_control_queue_)
511 uint8_t pipeline) {
512 PipelineContext &ps = this->pipelines_[pipeline];
513 media_source::MediaSource *active_source = ps.active_source;
514 bool has_internal_playlist = (active_source != nullptr) && active_source->has_internal_playlist();
515
516 // Determine target source: prefer active, fall back to last
517 media_source::MediaSource *target_source = nullptr;
518 if (active_source != nullptr) {
519 target_source = active_source;
520 } else if (ps.last_source != nullptr) {
521 target_source = ps.last_source;
522 }
523
524 switch (player_command) {
526 // Convert TOGGLE to PLAY or PAUSE based on current state
527 if ((active_source != nullptr) && (active_source->get_state() == media_source::MediaSourceState::PLAYING)) {
528 if (target_source != nullptr) {
530 }
531 } else if (!has_internal_playlist && active_source == nullptr && !ps.playlist.empty()) {
532 bool last_has_internal_playlist = (ps.last_source != nullptr) && ps.last_source->has_internal_playlist();
533 if (last_has_internal_playlist) {
535 } else {
536 if (ps.playlist_index >= ps.playlist.size()) {
537 ps.playlist_index = 0;
538 }
540 }
541 } else {
542 if (target_source != nullptr) {
544 }
545 }
546 break;
547 }
548
550 if (!has_internal_playlist && active_source == nullptr && !ps.playlist.empty()) {
551 bool last_has_internal_playlist = (ps.last_source != nullptr) && ps.last_source->has_internal_playlist();
552 if (last_has_internal_playlist) {
554 } else {
555 if (ps.playlist_index >= ps.playlist.size()) {
556 ps.playlist_index = 0;
557 }
559 }
560 } else if (target_source != nullptr) {
562 }
563 break;
564 }
565
567 if (target_source != nullptr) {
569 }
570 break;
571 }
572
574 if (!has_internal_playlist) {
575 this->cancel_timeout(PIPELINE_TIMEOUT_IDS[pipeline]);
576 ps.playlist.clear();
577 ps.shuffle_indices.clear();
578 ps.playlist_index = 0;
579 }
580 if (target_source != nullptr) {
582 }
583 break;
584 }
585
587 if (!has_internal_playlist) {
588 this->cancel_timeout(PIPELINE_TIMEOUT_IDS[pipeline]);
589 if (ps.playlist_index + 1 < ps.playlist.size()) {
590 ps.playlist_index++;
592 } else if (ps.repeat_mode == REPEAT_ALL && !ps.playlist.empty()) {
593 ps.playlist_index = 0;
595 }
596 } else if (target_source != nullptr) {
598 }
599 break;
600 }
601
603 if (!has_internal_playlist) {
604 this->cancel_timeout(PIPELINE_TIMEOUT_IDS[pipeline]);
605 if (ps.playlist_index > 0) {
606 ps.playlist_index--;
608 } else if (ps.repeat_mode == REPEAT_ALL && !ps.playlist.empty()) {
609 ps.playlist_index = ps.playlist.size() - 1;
611 }
612 } else if (target_source != nullptr) {
614 }
615 break;
616 }
617
619 if (!has_internal_playlist) {
621 } else if (target_source != nullptr) {
623 }
624 break;
625
627 if (!has_internal_playlist) {
629 } else if (target_source != nullptr) {
631 }
632 break;
633
635 if (!has_internal_playlist) {
637 } else if (target_source != nullptr) {
639 }
640 break;
641
643 if (!has_internal_playlist) {
644 this->cancel_timeout(PIPELINE_TIMEOUT_IDS[pipeline]);
645 if (ps.playlist_index < ps.playlist.size()) {
646 size_t actual_position = this->get_playlist_position_(pipeline);
647 ps.playlist[0] = std::move(ps.playlist[actual_position]);
648 ps.playlist.resize(1);
649 ps.playlist_index = 0;
650 } else {
651 ps.playlist.clear();
652 ps.playlist_index = 0;
653 }
654 ps.shuffle_indices.clear();
655 } else if (target_source != nullptr) {
657 }
658 break;
659 }
660
662 if (!has_internal_playlist) {
663 this->shuffle_playlist_(pipeline);
664 } else if (target_source != nullptr) {
666 }
667 break;
668
670 if (!has_internal_playlist) {
671 this->unshuffle_playlist_(pipeline);
672 } else if (target_source != nullptr) {
674 }
675 break;
676
677 default:
678 // TURN_ON, TURN_OFF, ENQUEUE (handled separately with URL) are no-ops
679 break;
680 }
681}
682
683// THREAD CONTEXT: Called from main loop only. Entry points:
684// - HA/automation commands (direct)
685// - handle_play_uri_request_() via make_call().perform() (deferred from source tasks)
687 if (!this->is_ready()) {
688 return;
689 }
690
691 MediaPlayerControlCommand control_command{};
692
693 // Determine which pipeline to use based on announcement flag, falling back if the preferred pipeline
694 // is not configured
695 auto announcement = call.get_announcement();
696 if (announcement.has_value() && announcement.value()) {
697 if (this->pipelines_[ANNOUNCEMENT_PIPELINE].is_configured()) {
698 control_command.pipeline = ANNOUNCEMENT_PIPELINE;
699 } else {
700 control_command.pipeline = MEDIA_PIPELINE;
701 }
702 } else {
703 if (this->pipelines_[MEDIA_PIPELINE].is_configured()) {
704 control_command.pipeline = MEDIA_PIPELINE;
705 } else {
706 control_command.pipeline = ANNOUNCEMENT_PIPELINE;
707 }
708 }
709
710 const auto &media_url = call.get_media_url();
711 if (media_url.has_value()) {
712 auto command = call.get_command();
713 bool enqueue = command.has_value() && command.value() == media_player::MEDIA_PLAYER_COMMAND_ENQUEUE;
714
715 if (enqueue) {
716 control_command.type = MediaPlayerControlCommand::ENQUEUE_URI;
717 } else {
718 control_command.type = MediaPlayerControlCommand::PLAY_URI;
719 }
720 // Heap allocation is unavoidable: URIs from Home Assistant are arbitrary-length (media URLs with tokens
721 // can easily exceed 500 bytes). Deleted in process_control_queue_() after the command is consumed. FreeRTOS queues
722 // require items to be copyable, so we store a pointer to the string in the queue rather than the string itself.
723 control_command.data.uri = new std::string(media_url.value());
724 if (xQueueSend(this->media_control_command_queue_, &control_command, 0) != pdTRUE) {
725 delete control_command.data.uri;
726 ESP_LOGE(TAG, "Queue full, URI dropped");
727 }
728 return;
729 }
730
731 auto volume = call.get_volume();
732 if (volume.has_value()) {
733 this->set_volume_(volume.value());
734 this->publish_state();
735 return;
736 }
737
738 auto cmd = call.get_command();
739 if (cmd.has_value()) {
740 switch (cmd.value()) {
742 this->set_mute_state_(true);
743 break;
745 this->set_mute_state_(false);
746 break;
748 this->set_volume_(std::min(1.0f, this->volume + this->volume_increment_));
749 break;
751 this->set_volume_(std::max(0.0f, this->volume - this->volume_increment_));
752 break;
753 default:
754 // Queue command for processing in loop()
755 control_command.type = MediaPlayerControlCommand::SEND_COMMAND;
756 control_command.data.command = cmd.value();
757 if (xQueueSend(this->media_control_command_queue_, &control_command, 0) != pdTRUE) {
758 ESP_LOGE(TAG, "Queue full, command dropped");
759 }
760 return;
761 }
762 this->publish_state();
763 }
764}
765
767 // This media player supports more traits like playlists, repeat, and shuffle, but the ESPHome API currently (March
768 // 2026) doesn't support those commands, so we only report pause support for now since that's used by the frontend and
769 // supported by our player.
770 auto traits = media_player::MediaPlayerTraits();
771 traits.set_supports_pause(true);
772
773 for (const auto &ps : this->pipelines_) {
774 if (ps.format.has_value()) {
775 traits.get_supported_formats().push_back(ps.format.value());
776 }
777 }
778
779 return traits;
780}
781
783 VolumeRestoreState volume_restore_state;
784 volume_restore_state.volume = this->volume;
785 volume_restore_state.is_muted = this->is_muted_;
786 this->pref_.save(&volume_restore_state);
787}
788
789void SpeakerSourceMediaPlayer::set_mute_state_(bool mute_state, bool publish) {
790 if (this->is_muted_ == mute_state) {
791 return;
792 }
793
794 for (auto &ps : this->pipelines_) {
795 if (ps.is_configured()) {
796 ps.speaker->set_mute_state(mute_state);
797 }
798 }
799
800 this->is_muted_ = mute_state;
801
802 if (publish) {
804 }
805
806 // Notify all media sources about the mute state change
807 for (auto &ps : this->pipelines_) {
808 for (auto &binding : ps.sources) {
809 binding->source->notify_mute_changed(mute_state);
810 }
811 }
812
813 if (mute_state) {
814 this->defer([this]() { this->mute_trigger_.trigger(); });
815 } else {
816 this->defer([this]() { this->unmute_trigger_.trigger(); });
817 }
818}
819
820void SpeakerSourceMediaPlayer::set_volume_(float volume, bool publish) {
821 // Remap the volume to fit within the configured limits. An effectively zero volume is passed through as zero so
822 // the speaker silences it, otherwise volume_min would make it audible.
823 float bounded_volume = (volume < speaker::SILENT_VOLUME_THRESHOLD)
824 ? 0.0f
825 : remap<float, float>(volume, 0.0f, 1.0f, this->volume_min_, this->volume_max_);
826
827 for (auto &ps : this->pipelines_) {
828 if (ps.is_configured()) {
829 ps.speaker->set_volume(bounded_volume);
830 }
831 }
832
833 if (publish) {
834 this->volume = volume;
835 }
836
837 // Notify all media sources about the volume change
838 for (auto &ps : this->pipelines_) {
839 for (auto &binding : ps.sources) {
840 binding->source->notify_volume_changed(volume);
841 }
842 }
843
844 if (publish) {
846 }
847
848 this->defer([this, volume]() { this->volume_trigger_.trigger(volume); });
849}
850
852 const PipelineContext &ps = this->pipelines_[pipeline];
853
854 if (ps.shuffle_indices.empty() || ps.playlist_index >= ps.shuffle_indices.size()) {
855 return ps.playlist_index;
856 }
857 return ps.shuffle_indices[ps.playlist_index];
858}
859
861 PipelineContext &ps = this->pipelines_[pipeline];
862
863 if (ps.playlist.size() <= 1) {
864 ps.shuffle_indices.clear();
865 return;
866 }
867
868 // Capture current actual position BEFORE modifying shuffle_indices
869 size_t current_actual = this->get_playlist_position_(pipeline);
870
871 // Build indices vector
872 ps.shuffle_indices.resize(ps.playlist.size());
873 for (size_t i = 0; i < ps.playlist.size(); i++) {
874 ps.shuffle_indices[i] = i;
875 }
876
877 // Fisher-Yates shuffle using ESPHome's random helper
878 for (size_t i = ps.shuffle_indices.size() - 1; i > 0; i--) {
879 size_t j = random_uint32() % (i + 1);
880 std::swap(ps.shuffle_indices[i], ps.shuffle_indices[j]);
881 }
882
883 // Move current track to current position (so playback continues seamlessly)
884 if (ps.playlist_index < ps.shuffle_indices.size()) {
885 for (size_t i = 0; i < ps.shuffle_indices.size(); i++) {
886 if (ps.shuffle_indices[i] == current_actual) {
887 std::swap(ps.shuffle_indices[i], ps.shuffle_indices[ps.playlist_index]);
888 break;
889 }
890 }
891 }
892}
893
895 PipelineContext &ps = this->pipelines_[pipeline];
896
897 if (!ps.shuffle_indices.empty() && ps.playlist_index < ps.shuffle_indices.size()) {
899 }
900 ps.shuffle_indices.clear();
901}
902
903} // namespace esphome::speaker_source
904
905#endif // USE_ESP32
bool cancel_timeout(const char *name)
Cancel a timeout function.
bool is_ready() const
void set_timeout(const char *name, uint32_t timeout, std::function< void()> &&f)
Set a timeout function with a const char* name.
Definition component.cpp:96
void defer(const char *name, std::function< void()> &&f)
Defer a callback to the next loop() call with a const char* name.
ESPPreferenceObject make_entity_preference(uint32_t version=0)
Create a preference object for storing this entity's state/settings.
void trigger(const Ts &...x) ESPHOME_ALWAYS_INLINE
Inform the parent automation that the event has triggered.
Definition automation.h:533
uint32_t bytes_to_frames(size_t bytes) const
Convert bytes to frames.
Definition audio.h:43
Abstract base class for media sources MediaSource provides audio data to an orchestrator via the Medi...
virtual void notify_audio_played(uint32_t frames, int64_t timestamp)
Notify the source about audio that has been played Called when the speaker reports that audio frames ...
virtual bool has_internal_playlist() const
Whether this source manages its own playlist internally Smart sources that handle next/previous/repea...
void set_listener(MediaSourceListener *listener)
Set the listener that receives callbacks from this source.
virtual void handle_command(MediaSourceCommand command)=0
Handle playback commands; e.g., pause, stop, next, etc.
MediaSourceState get_state() const
Get current playback state.
virtual bool play_uri(const std::string &uri)=0
Start playing the given URI Sources should validate the URI and state, returning false if the source ...
virtual size_t play(const uint8_t *data, size_t length)=0
Plays the provided audio data.
void set_audio_stream_info(const audio::AudioStreamInfo &audio_stream_info)
Definition speaker.h:97
audio::AudioStreamInfo & get_audio_stream_info()
Definition speaker.h:101
virtual void finish()
Definition speaker.h:60
bool is_stopped() const
Definition speaker.h:69
virtual void stop()=0
void shuffle_playlist_(uint8_t pipeline)
Generates shuffled indices for the playlist, keeping current track at current position.
void set_playlist_delay_ms(uint8_t pipeline, uint32_t delay_ms)
void queue_play_current_(uint8_t pipeline, uint32_t delay_ms=0)
void set_mute_state_(bool mute_state, bool publish=true)
Sets the mute state.
void queue_command_(MediaPlayerControlCommand::Type type, uint8_t pipeline)
media_source::MediaSource * find_source_for_uri_(const std::string &uri, uint8_t pipeline)
void unshuffle_playlist_(uint8_t pipeline)
Clears shuffle indices and adjusts playlist_index to maintain current track.
media_player::MediaPlayerState get_source_state_(media_source::MediaSource *media_source, bool playlist_active, media_player::MediaPlayerState old_state) const
Determine media player state from a pipeline's active source.
void handle_play_uri_request_(uint8_t pipeline, const std::string &uri)
void control(const media_player::MediaPlayerCall &call) override
void handle_player_command_(media_player::MediaPlayerCommand player_command, uint8_t pipeline)
size_t get_playlist_position_(uint8_t pipeline) const
Maps playlist_index through shuffle indices if shuffle is active.
void add_media_source(uint8_t pipeline, media_source::MediaSource *media_source)
Adds a media source to a pipeline and registers this player as its listener.
bool try_execute_play_uri_(const std::string &uri, uint8_t pipeline)
void set_volume_(float volume, bool publish=true)
Updates this->volume and saves volume/mute state to flash for restoration if publish is true.
void handle_media_state_changed_(uint8_t pipeline, media_source::MediaSource *source, media_source::MediaSourceState state)
media_player::MediaPlayerTraits get_traits() override
void handle_speaker_playback_callback_(uint32_t frames, int64_t timestamp, uint8_t pipeline)
void save_volume_restore_state_()
Saves the current volume and mute state to the flash for restoration.
size_t handle_media_output_(uint8_t pipeline, media_source::MediaSource *source, const uint8_t *data, size_t length, uint32_t timeout_ms, const audio::AudioStreamInfo &stream_info)
uint8_t type
bool state
Definition fan.h:2
const char * media_player_state_to_string(MediaPlayerState state)
ESPHOME_LOG_TAG(TAG, "speaker_source_media_player")
uint32_t random_uint32()
Return a random 32-bit unsigned integer.
Definition helpers.cpp:12
T remap(U value, U min, U max, T min_out, T max_out)
Remap value from the range (min, max) to (min_out, max_out).
Definition helpers.h:879
static void uint32_t
std::vector< std::unique_ptr< SourceBinding > > sources
std::atomic< media_source::MediaSource * > active_source
void request_play_uri(const std::string &uri) override
void report_state(media_source::MediaSourceState state) override
size_t write_audio(const uint8_t *data, size_t length, uint32_t timeout_ms, const audio::AudioStreamInfo &stream_info) override
uint16_t length
Definition tt21100.cpp:0
uint16_t timestamp
Definition tt21100.cpp:2