Last active
March 31, 2026 19:49
-
-
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 file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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