Last active
February 6, 2026 04:09
-
-
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/.
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
| 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) |
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
| 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) |
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
| 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