Skip to content

Instantly share code, notes, and snippets.

@Inobtenio
Last active February 6, 2026 04:09
Show Gist options
  • Select an option

  • Save Inobtenio/9e1840cbe3eb82a02c7e7c0ff9d57867 to your computer and use it in GitHub Desktop.

Select an option

Save Inobtenio/9e1840cbe3eb82a02c7e7c0ff9d57867 to your computer and use it in GitHub Desktop.
updater gets what's playing on Spotify, looks at the album cover and extracts the most dominant/eye-catching color as well as the track data, server serves said data alongside a preformatted album cover for its use in the Vobot Mini Dock. More info at https://inobtenio.com/posts/vobot-mini-dock-spotify/.
import lvgl as lv
import arequests
import ubinascii
import time
import uos
import peripherals
import uasyncio as asyncio
# App Name
NAME = "Spotify"
# A file path or data (bytes type) of the logo image for this app.
# If not specified, the default icon will be applied.
ICON = f"A:apps/{NAME}/resources/Primary_Logo_Green_RGB.png"
# CAN_BE_AUTO_SWITCHED = True
app_mgr = None
CURRENT_SONG_DATA_URL = "http://10.10.0.124:4444/current"
CURRENT_SONG_ALBUM_COVER_URL = "http://10.10.0.124:4444/cover"
NETWORK_TIMEOUT=2
# LVGL widgets
scr = None
# Screen resolutin
SCR_WIDTH, SCR_HEIGHT = peripherals.screen.screen_resolution
SCR_SIDE_PADDING = 8
COVER_WIDTH = 160
COVER_HEIGHT = 160
COVER_IMAGE_Y = -30
PLAYBACK_STATUS_LOGO_WIDTH = 20
PLAYBACK_STATUS_LOGO_HEIGHT = 20
SPOTIFY_LOGO_WIDTH = 40
SPOTIFY_LOGO_HEIGHT = 40
SONG_LABEL_Y = 70
ARTIST_LABEL_Y = 95
WILDCARD = 5
# LVGL widgets
spotify_logo = None
playback_status_logo = None
song_label = None
artist_label = None
cover_image = None
progress_bar = None
background_style = None
counter = 0
progress_percent = 0
duration = 0
track_id = None
async def get_currently_playing():
response = await arequests.get(
CURRENT_SONG_DATA_URL,
timeout=NETWORK_TIMEOUT
)
if response.status_code == 200 and response.content:
data = await response.json()
await response.close()
return data
else:
print("Nothing is currently playing.")
return {}
async def on_running_foreground():
# Once this App becomes ACTIVE, called by system approx. every 200ms
global counter, progress_percent, duration, track_id
counter += 1
if counter % 5 == 0:
data = await get_currently_playing()
response = await arequests.get(
CURRENT_SONG_ALBUM_COVER_URL,
timeout=NETWORK_TIMEOUT
)
song_name = data["song_name"]
artist_names = data["artist_names"]
album_cover_color = data["album_cover_main_color"]
album_cover_color_is_light = data["album_cover_main_color_is_light"]
duration = data["duration"]
progress_percent = int((data["progress"]/duration)*100)
is_playing = data["is_playing"]
track_has_changed = track_id != data["id"]
track_id = data["id"]
if response.status_code == 200 and response.content:
cnt = await response.content
await response.close()
if is_playing:
# if album_cover_color_is_light:
# playback_status_logo.set_src(f"A:apps/{NAME}/resources/play-black.png")
# else:
playback_status_logo.set_src(f"A:apps/{NAME}/resources/play-white.png")
else:
# if album_cover_color_is_light:
# playback_status_logo.set_src(f"A:apps/{NAME}/resources/pause-black.png")
# else:
playback_status_logo.set_src(f"A:apps/{NAME}/resources/pause-white.png")
progress_bar.set_value(progress_percent, lv.ANIM.ON)
if track_has_changed:
if album_cover_color_is_light:
spotify_logo.set_src(f"A:apps/{NAME}/resources/Spotify_Primary_Logo_RGB_Black.png")
else:
spotify_logo.set_src(f"A:apps/{NAME}/resources/Spotify_Primary_Logo_RGB_White.png")
song_label.set_text(song_name)
artist_label.set_text(artist_names)
background_style.set_bg_color(lv.color_hex(int(album_cover_color.lstrip("#"), 16)))
lv.scr_act().add_style(background_style, 0)
img_dsc = lv.img_dsc_t(
{
"header": {"w": COVER_WIDTH, "h": COVER_HEIGHT},
"data_size": len(cnt),
"data": cnt
}
)
cover_image.set_src(img_dsc)
cover_image.set_style_opa(lv.OPA._100, 0)
counter = 0
else:
print("Nothing is currently playing.")
async def on_stop():
# User triggered to leave this App. This App is no longer visible. all function should be deactivated
# This App becomes STOPPED state
global scr
print('on stop')
if scr:
scr.clean()
del scr
scr = None
async def on_start():
# User triggered to enter this App for the first time, or from STOPPED state, all function should be initialed
# Then, this App becomes STARTED state
global scr, spotify_logo, playback_status_logo, song_label, artist_label, cover_image, progress_bar, background_style
print('on start')
# Create the LVGL widgets.
scr = lv.obj()
style = lv.style_t()
style.init()
style.set_text_align(lv.TEXT_ALIGN.CENTER)
background_style = lv.style_t()
background_style.init()
background_style.set_bg_opa(lv.OPA.COVER)
background_style.set_bg_grad_dir(lv.GRAD_DIR.VER)
background_style.set_bg_grad_color(lv.color_hex(0x000000))
# Create the LVGL widgets.
spotify_logo = lv.img(scr)
spotify_logo.set_size(SPOTIFY_LOGO_WIDTH, SPOTIFY_LOGO_HEIGHT)
spotify_logo.align(lv.ALIGN.CENTER, int(SCR_WIDTH/2) - SCR_SIDE_PADDING - int(SPOTIFY_LOGO_WIDTH/2) + WILDCARD, -int(SCR_HEIGHT/2) + SCR_SIDE_PADDING + int(SPOTIFY_LOGO_HEIGHT/2) - WILDCARD)
# spotify_logo.align(lv.ALIGN.CENTER, 136, -97)
spotify_logo.set_src(f"A:apps/{NAME}/resources/Spotify_Primary_Logo_RGB_White.png")
spotify_logo.set_style_opa(lv.OPA._50, 0)
song_label = lv.label(scr)
song_label.align(lv.ALIGN.CENTER, 0, SONG_LABEL_Y)
# song_label.set_text('Nothing is playing')
song_label.set_style_text_font(lv.font_ascii_bold_28, 0)
song_label.set_width(SCR_WIDTH - 2*SCR_SIDE_PADDING)
song_label.set_long_mode(lv.label.LONG.SCROLL_CIRCULAR)
song_label.add_style(style, lv.PART.MAIN)
artist_label = lv.label(scr)
artist_label.align(lv.ALIGN.CENTER, 0, ARTIST_LABEL_Y)
artist_label.set_style_text_font(lv.font_ascii_18, 0)
artist_label.set_width(SCR_WIDTH - 2*SCR_SIDE_PADDING - 2*PLAYBACK_STATUS_LOGO_WIDTH - 2*WILDCARD)
artist_label.set_long_mode(lv.label.LONG.SCROLL_CIRCULAR)
artist_label.add_style(style, lv.PART.MAIN)
playback_status_logo = lv.img(scr)
playback_status_logo.set_size(PLAYBACK_STATUS_LOGO_WIDTH, PLAYBACK_STATUS_LOGO_HEIGHT)
playback_status_logo.align(lv.ALIGN.CENTER, -int(SCR_WIDTH/2) + SCR_SIDE_PADDING + int(PLAYBACK_STATUS_LOGO_WIDTH/2) - WILDCARD, int(SCR_HEIGHT/2) - SCR_SIDE_PADDING - int(PLAYBACK_STATUS_LOGO_HEIGHT/2) + WILDCARD)
# playback_status_logo.align(lv.ALIGN.CENTER, -143, 106)
playback_status_logo.set_src(f"A:apps/{NAME}/resources/pause-white.png")
playback_status_logo.set_style_opa(lv.OPA._50, 0)
cover_image = lv.img(scr)
cover_image.set_size(COVER_WIDTH, COVER_HEIGHT)
cover_image.set_src(f"A:apps/{NAME}/resources/placeholder.png")
cover_image.align(lv.ALIGN.CENTER, 0, COVER_IMAGE_Y)
cover_image.set_style_opa(lv.OPA._60, 0)
progress_bar = lv.bar(scr)
progress_bar.set_size(SCR_WIDTH - SCR_SIDE_PADDING - PLAYBACK_STATUS_LOGO_WIDTH - WILDCARD, WILDCARD)
progress_bar.align(lv.ALIGN.CENTER, int((SCR_SIDE_PADDING + PLAYBACK_STATUS_LOGO_WIDTH + WILDCARD)/2), int(SCR_HEIGHT/2) - WILDCARD)
progress_bar.set_style_bg_color(lv.palette_main(lv.PALETTE.BLUE), 0)
progress_bar.set_style_radius(WILDCARD, 0)
progress_bar.set_style_bg_color(lv.palette_main(lv.PALETTE.GREEN), lv.PART.INDICATOR)
progress_bar.set_value(100, lv.ANIM.ON)
progress_bar.set_range(0, 100)
# Load the LVGL widgets.
lv.scr_load(scr)
import json
from flask import Flask, send_from_directory
from flask_restful import Resource, Api
app = Flask(__name__)
api = Api(app)
class CurrentSong(Resource):
def get(self):
with open('data.json') as f:
d = json.load(f)
return d
class CurrentSongCover(Resource):
def get(self):
return send_from_directory('', 'cover.jpg')
class CurrentSongFullCover(Resource):
def get(self):
return send_from_directory('', 'full_cover.jpg')
api.add_resource(CurrentSong, '/current')
api.add_resource(CurrentSongCover, '/cover')
api.add_resource(CurrentSongFullCover, '/full-cover')
if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0', port=4444)
import os
import sys
import PIL
import json
import time
import asyncio
import requests
import logging
import binascii
from PIL import Image, ImageDraw
from io import BytesIO
import math
import colorsys
import numpy
import numpy as np
from sklearn.cluster import KMeans
from json.decoder import JSONDecodeError
CLIENT_ID = os.getenv("SPOTIFY_CLIENT_ID")
CLIENT_SECRET = os.getenv("SPOTIFY_CLIENT_SECRET")
REFRESH_TOKEN = os.getenv("SPOTIFY_REFRESH_TOKEN")
CHROMA_COEF = 4.9226
DARKNESS_COEF = 1.4060
DOMINANCE_COEF = 0.7932
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[logging.StreamHandler(sys.stdout)]
)
def patch_asscalar(a):
return a.item()
setattr(numpy, "asscalar", patch_asscalar)
def is_color_light(rgb):
r, g, b = rgb
brightness = 0.299 * r + 0.587 * g + 0.114 * b
return brightness > 135
def calculate_dark_colorfulness(rgb):
red, green, blue = [x / 255.0 for x in rgb]
red_greenness = red - green # or the a component
yellow_blueness = (red + green)/2 - blue # or the b component. red + green output yellow in additive color (light)
chroma = math.sqrt(red_greenness ** 2 + yellow_blueness ** 2)
luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue # Relative luminance
darkness = 1 - luminance
return (chroma, darkness)
def increase_saturation(rgb, amount=0.2):
r, g, b = [x / 255.0 for x in rgb]
h, l, s = colorsys.rgb_to_hls(r, g, b)
s = min(1.0, s + amount) # Cap at 1.0
r_new, g_new, b_new = colorsys.hls_to_rgb(h, l, s)
return tuple(int(x * 255) for x in (r_new, g_new, b_new))
def improve_white_contrast(rgb, brightness_factor=0.85, saturation_boost=1.1):
"""
Improve contrast of a color against white by darkening and boosting saturation.
Args:
rgb (tuple): (R, G, B) tuple in 0–255.
brightness_factor (float): < 1 to darken.
saturation_boost (float): > 1 to increase saturation.
Returns:
tuple: Adjusted (R, G, B) in 0–255.
"""
r, g, b = [x / 255 for x in rgb]
h, s, v = colorsys.rgb_to_hsv(r, g, b)
s = min(s * saturation_boost, 1.0)
v = max(v * brightness_factor, 0.0) # darken
r, g, b = colorsys.hsv_to_rgb(h, s, v)
return tuple(int(x * 255) for x in (r, g, b))
def extract_color_clusters(num_clusters):
image = Image.open("./full_cover.jpg").convert("RGB")
w, h = image.size
try:
pixels = np.array(image).reshape(-1, 3) # Turn a RGB matrix into an RGB 2D array
# Cluster colors using K-Means
kmeans = KMeans(n_clusters=num_clusters, random_state=0, n_init="auto")
kmeans.fit(pixels)
labels = kmeans.labels_
colors = []
# Group colors by cluster and calculate average for each
clusters = [[] for _ in range(num_clusters)]
for i, label in enumerate(labels):
clusters[label].append(pixels[i])
for group in clusters:
color = np.mean(group, axis=0)
chroma, darkness = calculate_dark_colorfulness(color)
dominance = len(group)/(w*h)
score = chroma * CHROMA_COEF + darkness * DARKNESS_COEF + dominance * DOMINANCE_COEF
colors.append({
'color': tuple(int(c) for c in color),
'chroma': chroma,
'darkness': darkness,
'dominance': dominance,
'score': score#,
})
return colors
except Exception as e:
return [{
'color': (128,128,128),
'chroma': 0,
'darkness': 0.75,
'dominance': 1.0,
'score': 1.0
}]
def get_best_color():
return max(extract_color_clusters(20), key=lambda c: c['score'])
def get_basic_auth_header():
creds = f"{CLIENT_ID}:{CLIENT_SECRET}"
return binascii.b2a_base64(creds.encode()).strip().decode()
def get_access_token(need_new_token, access_token):
if not need_new_token:
return access_token
else:
auth_header = get_basic_auth_header()
headers = {
"Authorization": f"Basic {auth_header}",
"Content-Type": "application/x-www-form-urlencoded"
}
payload = f"grant_type=refresh_token&refresh_token={REFRESH_TOKEN}"
response = requests.post(
"https://accounts.spotify.com/api/token",
data=payload,
headers=headers
)
try:
data = response.json()
except JSONDecodeError:
return "JSONDecodeError"
except Exception as e:
return f"{e}"
return data["access_token"]
def get_currently_playing(access_token):
headers = {
"Authorization": f"Bearer {access_token}"
}
response = requests.get(
"https://api.spotify.com/v1/me/player/currently-playing?additional_types=episode",
headers=headers
)
need_new_token = True
current_track_data = {}
if response.status_code == 200 and response.content:
data = response.json()
track = data["item"]
current_track_data["id"] = track["id"]
current_track_data["song_name"] = track["name"]
if data["currently_playing_type"] == "episode":
current_track_data["artist_names"] = track["show"]["publisher"]
get_currently_playing_cover(track["images"][0]["url"])
else:
current_track_data["artist_names"] = ', '.join(artist["name"] for artist in track["artists"])
get_currently_playing_cover(track["album"]["images"][0]["url"])
highlighted_color = improve_white_contrast(get_best_color()["color"], 0.80, 1.7)
logging.info(highlighted_color)
if isinstance(highlighted_color, int):
highlighted_color = (highlighted_color, highlighted_color, highlighted_color)
current_track_data["album_cover_main_color"] = '#%02x%02x%02x' % highlighted_color
current_track_data["album_cover_main_color_is_light"] = is_color_light(highlighted_color)
current_track_data["progress"] = data["progress_ms"] / 1000
current_track_data["duration"] = track["duration_ms"] / 1000
current_track_data["is_playing"] = data["is_playing"]
logging.info(f'{current_track_data["song_name"]} - {current_track_data["artist_names"]}')
with open("data.json", 'w') as file:
#json.dump(track_response.json(), file, indent=2)
json.dump(current_track_data, file, indent=2)
need_new_token = False
elif response.status_code == 401:
need_new_token = False
else:
logging.info(response.content)
logging.info("Another unsuccessful response when retrieving the access token.")
return need_new_token
def get_currently_playing_cover(url):
response = requests.get(url)
if response.status_code == 200 and response.content:
image = Image.open(BytesIO(response.content))
new_image = image.resize((160, 160), PIL.Image.BILINEAR)
#new_image = new_image.convert("RGBA")
image.save("full_cover.jpg", format="JPEG")
new_image.save("cover.jpg", format="JPEG", quality=40, optimize=True)
return image
else:
logging.info("Nothing is currently playing.")
def run_every_n_seconds():
need_new_token = True
access_token = None
while True:
access_token = get_access_token(need_new_token, access_token)
need_new_token = get_currently_playing(access_token)
time.sleep(2)
if __name__ == '__main__':
run_every_n_seconds()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment