Skip to content

Instantly share code, notes, and snippets.

@kahrendt
Last active March 31, 2026 19:49
Show Gist options
  • Select an option

  • Save kahrendt/a86b20601f02df9f3861a48cedc98e17 to your computer and use it in GitHub Desktop.

Select an option

Save kahrendt/a86b20601f02df9f3861a48cedc98e17 to your computer and use it in GitHub Desktop.
This is minimal demonstration on how to obtain text metadata and album art from Sendspin and display it using LVGL on an ESP32-S3 Box 3.
# This is minimal demonstration on how to obtain text metadata and album art from Sendspin and display it using LVGL on an ESP32-S3 Box 3.
# The track title is displayed below the cover art will scroll if too long for the screen.
# Touching the screen pops up controls for 5 seconds where you can pause/resume and go to the previous/next track
# Swiping left or right will go to a different page. It should show a picture of the artist (but there are some bugs on the MA side, so this doesn't work consistently)
# The main page displays the track progress in m:ss format on the lower left and the track duration on the right in m:ss format.
---
substitutions:
name: esp32-s3-box-3
friendly_name: ESP32 S3 Box 3 Sendspin
font_glyphsets: "GF_Latin_Core"
font_family: Figtree
mdi_pause_glyph: \U000F03E4
mdi_play_glyph: \U000F040A
mdi_skip_previous_glyph: \U000F04AE
mdi_skip_next_glyph: \U000F04AD
# Time to wait before verifying optimistic state changes match actual state
optimistic_state_correction_delay: 1000ms
esphome:
name: ${name}
friendly_name: ${friendly_name}
min_version: 2026.3.0
name_add_mac_suffix: true
project:
name: esphome.sendspin-metadata
version: dev
on_boot:
priority: 600
then:
- lvgl.page.show: idle_page
esp32:
board: esp32s3box
flash_size: 16MB
cpu_frequency: 240MHz
framework:
type: esp-idf
sdkconfig_options:
CONFIG_ESP32S3_DEFAULT_CPU_FREQ_240: "y"
CONFIG_ESP32S3_DATA_CACHE_64KB: "y"
CONFIG_ESP32S3_DATA_CACHE_LINE_64B: "y"
psram:
mode: octal
speed: 80MHz
api:
ota:
- platform: esphome
id: ota_esphome
logger:
hardware_uart: USB_SERIAL_JTAG
level: DEBUG
logs:
sensor: WARN
wifi:
ap:
on_connect:
- delay: 5s
- ble.disable:
on_disconnect:
- ble.enable:
captive_portal:
improv_serial:
esp32_improv:
authorizer: none
button:
- platform: factory_reset
id: factory_reset_btn
internal: true
sensor:
- platform: sendspin
type: track_duration
id: sendspin_track_duration
on_value:
- lambda: |-
if ((uint32_t)x == 0) {
// Duration of 0 means unknown/undefined length
lv_label_set_text(id(track_duration_label), "");
} else {
uint32_t seconds = (uint32_t)x / 1000;
uint32_t minutes = seconds / 60;
seconds %= 60;
char buf[16];
snprintf(buf, sizeof(buf), "%d:%02d", minutes, seconds);
lv_label_set_text(id(track_duration_label), buf);
}
binary_sensor:
- platform: gpio
pin:
number: GPIO0
mode: INPUT_PULLUP
inverted: true
id: left_top_button
internal: true
on_multi_click:
- timing:
- ON for at least 10s
then:
- button.press: factory_reset_btn
# Home button (red circle below display)
- platform: gt911
name: "Home Button"
id: home_button
index: 0
on_press:
- lvgl.page.show: idle_page
- logger.log: "Home button pressed"
output:
- platform: ledc
pin: GPIO47 # Changed from GPIO45 for Box 3
id: backlight_output
light:
- platform: monochromatic
id: led
name: Screen
icon: "mdi:television"
entity_category: config
output: backlight_output
restore_mode: RESTORE_DEFAULT_ON
default_transition_length: 250ms
i2c:
scl: GPIO18
sda: GPIO8
scan: true # Enable I2C scanning to detect devices
# GT911 Touchscreen configuration
touchscreen:
- platform: gt911
id: touchscreen_gt911
interrupt_pin: GPIO3 # Touch interrupt pin for Box 3
# Note: reset_pin GPIO48 is shared with LCD, so not specified here
on_touch:
- lambda: |-
ESP_LOGI("touch", "Touch at (%d, %d)", touch.x, touch.y);
// Reset media controls hide timer if overlay is visible
if (!lv_obj_has_flag(id(media_controls_overlay), LV_OBJ_FLAG_HIDDEN)) {
id(hide_media_controls).stop();
id(hide_media_controls).execute();
}
external_components:
- source: github://pr#14933
components: [const, generic_image, sendspin]
refresh: 0s
sendspin:
id: sendspin_hub
generic_image:
- platform: sendspin
id: sendspin_cover_art
format: jpg
type: rgb565
resize: 200x200
image_source: ALBUM
on_image_decoded:
- if:
condition:
lvgl.page.is_showing: sendspin_page
then:
# Built-in lvgl.image.update action doesn't support fading
# Update image and fade in after decoding
- lambda: |-
lv_obj_t* img = id(album_art_widget);
// Update image source
lv_img_set_src(img, id(sendspin_cover_art));
lv_img_set_zoom(img, LV_IMG_ZOOM_NONE);
// Fade in new image
static lv_anim_t fade_in;
lv_anim_init(&fade_in);
lv_anim_set_var(&fade_in, img);
lv_anim_set_values(&fade_in, 0, 255);
lv_anim_set_time(&fade_in, 300); // 300ms fade in
lv_anim_set_exec_cb(&fade_in, [](void* obj, int32_t v) {
lv_obj_set_style_img_opa((lv_obj_t*)obj, v, 0);
});
lv_anim_start(&fade_in);
else:
# Page isn't currently open, don't bother fading
- lvgl.image.update:
id: album_art_widget
src: sendspin_cover_art
on_image_error:
- logger.log: "Failed to decode cover art image"
- platform: sendspin
id: sendspin_artist_picture
format: jpg
type: rgb565
resize: 200x200
image_source: ARTIST
on_image_decoded:
- lvgl.image.update:
id: artist_picture_widget
src: sendspin_artist_picture
- logger.log: "Decoded artist picture"
on_image_error:
- logger.log: "Failed to decode artist picture"
media_player:
- platform: sendspin
id: sendspin_media_player
name: Sendspin Player
on_play:
# Update play/pause icon
- lambda: lv_label_set_text(id(play_pause_icon), "${mdi_pause_glyph}");
- if:
condition:
lvgl.page.is_showing: idle_page
then:
- lvgl.page.show:
id: sendspin_page
animation: FADE_IN
time: 300ms
on_pause:
# Update play/pause icon
- lambda: lv_label_set_text(id(play_pause_icon), "${mdi_play_glyph}");
on_idle:
# Update play/pause icon
- lambda: lv_label_set_text(id(play_pause_icon), "${mdi_play_glyph}");
- if:
condition:
not:
lvgl.page.is_showing: idle_page
then:
- lvgl.page.show:
id: idle_page
animation: FADE_OUT
time: 300ms
switch:
- platform: gpio
name: Speaker Enable
pin: GPIO46
restore_mode: RESTORE_DEFAULT_ON
entity_category: config
disabled_by_default: true
script:
- id: hide_media_controls
mode: restart # Restart the timer if called again
then:
- delay: 5s
- lvgl.widget.hide: media_controls_overlay
- logger.log: "Hiding media controls"
interval:
# Probably overkill to update this every 250 ms
- interval: 0.25s
then:
- if:
condition:
- media_player.is_playing: sendspin_media_player
then:
- sendspin.get_track_progress:
then:
- lambda: |-
uint32_t seconds = x / 1000;
uint32_t minutes = seconds / 60;
seconds %= 60;
char buf[16];
snprintf(buf, sizeof(buf), "%d:%02d", minutes, seconds);
lv_label_set_text(id(track_progress_label), buf);
image:
- file: https://github.com/esphome/wake-word-voice-assistants/raw/main/error_box_illustrations/error-no-wifi.png
id: error_no_wifi
resize: 320x240
type: RGB
transparency: alpha_channel
- file: https://github.com/esphome/wake-word-voice-assistants/raw/main/error_box_illustrations/error-no-ha.png
id: error_no_ha
resize: 320x240
type: RGB
transparency: alpha_channel
font:
- file:
type: gfonts
family: ${font_family}
weight: 300
id: font_display
size: 20
glyphsets:
- ${font_glyphsets}
# Use a mono font to make track progress colon in the same position as it increments
- file:
type: gfonts
family: Roboto Mono
weight: 400
id: font_time
size: 16
glyphsets:
- ${font_glyphsets}
# Material Design Icons font for media controls
- file: https://github.com/Templarian/MaterialDesign-Webfont/raw/refs/heads/master/fonts/materialdesignicons-webfont.ttf
id: font_mdi
size: 40
glyphs: [
"\U000F03E4", # mdi:pause
"\U000F040A", # mdi:play
"\U000F04AD", # mdi:skip-next
"\U000F04AE", # mdi:skip-previous
]
text_sensor:
- platform: sendspin
id: track_title
type: title
on_value:
- lambda: |-
lv_label_set_text(id(track_progress_label), "");
- component.update: now_playing_text
- platform: sendspin
id: album_title
type: album
- platform: sendspin
id: track_artist
type: artist
on_value:
- component.update: now_playing_text
- platform: sendspin
id: album_year
type: year
- platform: sendspin
id: track_number
type: track
- platform: template
id: now_playing_text
lambda: |-
std::string playing_text = id(track_title).get_state();
if (!id(track_artist).get_state().empty()) {
if (!playing_text.empty()) {
playing_text += " - ";
}
playing_text += id(track_artist).get_state();
}
return playing_text;
on_value:
- lvgl.label.update:
id: now_playing_label
text: !lambda return x;
update_interval: never
color:
- id: sendspin_bg_color
hex: "000000"
spi:
- id: spi_bus
clk_pin: 7
mosi_pin: 6
display:
- platform: ili9xxx
id: s3_box_lcd
model: S3BOX
invert_colors: false
data_rate: 40MHz
cs_pin: 5
dc_pin: 4
reset_pin:
number: 48
inverted: true
auto_clear_enabled: false
update_interval: never
lvgl:
displays:
- s3_box_lcd
touchscreens:
- touchscreen_gt911
buffer_size: 25%
color_depth: 16
bg_color: 0x000000
default_font: font_display
top_layer:
widgets:
# Media controls overlay (initially hidden) - works on all pages
- obj:
id: media_controls_overlay
align: TOP_MID
width: 320
height: 80
bg_color: 0x000000
bg_opa: 60% # Semi-transparent background
border_width: 0
radius: 10
scrollbar_mode: "OFF" # Hide scrollbars
scrollable: false # Disable scrolling completely
hidden: true # Initially hidden
widgets:
# Previous button - doesn't currently work with current media player
- button:
id: prev_button
align: LEFT_MID
x: 40
width: 70
height: 70
bg_color: 0x404040
bg_opa: 70%
radius: 35
on_click:
- media_player.previous:
- logger.log: "Previous button pressed"
- script.stop: hide_media_controls
- script.execute: hide_media_controls
widgets:
- label:
align: CENTER
text: "\U000F04AE" # mdi:skip-previous
text_color: 0xFFFFFF
text_font: font_mdi
# Play/Pause button
- button:
id: play_pause_button
align: CENTER
width: 70
height: 70
bg_color: 0x404040
bg_opa: 70%
radius: 35
on_click:
- media_player.toggle: sendspin_media_player
- logger.log: "Play/Pause button pressed"
- lambda: |-
// Optimistically toggle the icon for immediate feedback
const char* current_text = lv_label_get_text(id(play_pause_icon));
if (strcmp(current_text, "${mdi_pause_glyph}") == 0) {
lv_label_set_text(id(play_pause_icon), "${mdi_play_glyph}");
} else {
lv_label_set_text(id(play_pause_icon), "${mdi_pause_glyph}");
}
- delay: ${optimistic_state_correction_delay}
- lambda: |-
// After delay, ensure icon matches actual state
if (id(sendspin_media_player)->state == media_player::MediaPlayerState::MEDIA_PLAYER_STATE_PLAYING) {
lv_label_set_text(id(play_pause_icon), "${mdi_pause_glyph}");
} else {
lv_label_set_text(id(play_pause_icon), "${mdi_play_glyph}");
}
- script.stop: hide_media_controls
- script.execute: hide_media_controls
widgets:
- label:
id: play_pause_icon
align: CENTER
text: "\U000F03E4" # mdi:pause (default)
text_color: 0xFFFFFF
text_font: font_mdi
# Next button - doesn't currently work with current media player
- button:
id: next_button
align: RIGHT_MID
x: -40
width: 70
height: 70
bg_color: 0x404040
bg_opa: 70%
radius: 35
on_click:
- media_player.next:
- logger.log: "Next button pressed"
- script.stop: hide_media_controls
- script.execute: hide_media_controls
widgets:
- label:
align: CENTER
text: "\U000F04AD" # mdi:skip-next
text_color: 0xFFFFFF
text_font: font_mdi
pages:
- id: idle_page
bg_color: 0x000000
skip: true
on_swipe_right:
- lambda: 'lv_indev_wait_release(lv_indev_get_act());'
- lvgl.page.next:
animation: OUT_RIGHT
time: 300ms
- logger.log: "Swiped right"
on_swipe_left:
- lambda: 'lv_indev_wait_release(lv_indev_get_act());'
- lvgl.page.previous:
animation: OUT_LEFT
time: 300ms
- logger.log: "Swiped left"
widgets:
- label:
id: idle_label
align: CENTER
text: "Sendspin Player"
text_color: 0xFFFFFF
text_font: font_display
clickable: true
on_click:
- lvgl.page.show: sendspin_page
- obj:
id: touch_capture_idle
x: 0
y: 0
width: 320
height: 240
bg_opa: TRANSP
border_opa: TRANSP
clickable: true
on_click:
- lambda: |-
if (id(sendspin_media_player)->state == media_player::MediaPlayerState::MEDIA_PLAYER_STATE_PLAYING) {
lv_label_set_text(id(play_pause_icon), "\U000F03E4");
} else {
lv_label_set_text(id(play_pause_icon), "\U000F040A");
}
- lvgl.widget.show: media_controls_overlay
- logger.log: "Showing media controls"
- script.execute: hide_media_controls
- id: sendspin_page
bg_color: 0x000000
scrollbar_mode: "OFF" # Hide scrollbars on main page
on_swipe_right:
- lambda: 'lv_indev_wait_release(lv_indev_get_act());'
- lvgl.page.next:
animation: OUT_RIGHT
time: 300ms
- logger.log: "Swiped right"
on_swipe_left:
- lambda: 'lv_indev_wait_release(lv_indev_get_act());'
- lvgl.page.previous:
animation: OUT_LEFT
time: 300ms
- logger.log: "Swiped left"
widgets:
- image:
id: album_art_widget
align: TOP_MID
src: sendspin_cover_art
height: 200
width: 200
antialias: true # Better quality when scaling
clickable: false
- obj:
id: track_info_container
x: 0
y: 200
width: 320
height: 40
bg_color: 0x000000
border_width: 0
pad_all: 0
scrollbar_mode: "OFF" # Hide scrollbars
clickable: false
widgets:
- label:
id: now_playing_label
align: CENTER
text_color: 0xFFFFFF
text_font: font_display
text: ""
width: 300 # Slightly less than container width for padding
long_mode: SCROLL_CIRCULAR # Continuously scroll text that doesn't fit
# Track progress time (left of album art)
- label:
id: track_progress_label
x: 0
y: 178
width: 60
height: 20
text_align: CENTER
text_color: 0xAAAAAA
text_font: font_time
text: ""
# Track duration time (right of album art)
- label:
id: track_duration_label
x: 260
y: 178
width: 60
height: 20
text_align: CENTER
text_color: 0xAAAAAA
text_font: font_time
text: ""
- obj:
id: touch_capture
x: 0
y: 0
width: 320
height: 240
bg_opa: TRANSP
border_opa: TRANSP
clickable: true
on_click:
- lambda: |-
if (id(sendspin_media_player)->state == media_player::MediaPlayerState::MEDIA_PLAYER_STATE_PLAYING) {
lv_label_set_text(id(play_pause_icon), "\U000F03E4");
} else {
lv_label_set_text(id(play_pause_icon), "\U000F040A");
}
- lvgl.widget.show: media_controls_overlay
- logger.log: "Showing media controls"
- script.execute: hide_media_controls
- id: sendspin_artist_page
bg_color: 0x000000
scrollbar_mode: "OFF" # Hide scrollbars on main page
on_swipe_right:
- lambda: 'lv_indev_wait_release(lv_indev_get_act());'
- lvgl.page.next:
animation: OUT_RIGHT
time: 300ms
- logger.log: "Swiped right"
on_swipe_left:
- lambda: 'lv_indev_wait_release(lv_indev_get_act());'
- lvgl.page.previous:
animation: OUT_LEFT
time: 300ms
- logger.log: "Swiped left"
widgets:
- image:
id: artist_picture_widget
align: TOP_MID
src: sendspin_artist_picture
height: 200
width: 200
antialias: true # Better quality when scaling
clickable: true
- obj:
id: touch_capture_artist
x: 0
y: 0
width: 320
height: 240
bg_opa: TRANSP
border_opa: TRANSP
clickable: true
on_click:
- lambda: |-
if (id(sendspin_media_player)->state == media_player::MediaPlayerState::MEDIA_PLAYER_STATE_PLAYING) {
lv_label_set_text(id(play_pause_icon), "\U000F03E4");
} else {
lv_label_set_text(id(play_pause_icon), "\U000F040A");
}
- lvgl.widget.show: media_controls_overlay
- logger.log: "Showing media controls"
- script.execute: hide_media_controls
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment