Created
September 27, 2025 13:55
-
-
Save Joabutt/07037f1ac2a779a4b7f9e02bbeab78d6 to your computer and use it in GitHub Desktop.
Spotify Playing for UsbPCMonitor 3.5" [Landscape version]
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 io | |
| import time | |
| import tempfile | |
| import uuid | |
| import atexit | |
| import requests | |
| import threading | |
| from PIL import Image, ImageDraw, ImageFont | |
| import spotipy | |
| from spotipy.oauth2 import SpotifyOAuth | |
| import win32con, win32gui | |
| from library.lcd.lcd_comm import Orientation | |
| from library.lcd.lcd_comm_rev_a import LcdCommRevA | |
| # === Config === | |
| SCRIPT_DIR = os.path.dirname(os.path.abspath(sys.argv[0])) | |
| os.chdir(SCRIPT_DIR) | |
| DISPLAY_WIDTH = 480 | |
| DISPLAY_HEIGHT = 320 | |
| COM_PORT = "AUTO" | |
| SPOTIPY_CLIENT_ID = "" | |
| SPOTIPY_CLIENT_SECRET = "" | |
| SPOTIPY_REDIRECT_URI = "" | |
| SCOPE = "user-read-playback-state user-read-currently-playing" | |
| POLL_INTERVAL = 1.0 | |
| # === Spotify setup === | |
| auth_manager = SpotifyOAuth( | |
| client_id=SPOTIPY_CLIENT_ID, | |
| client_secret=SPOTIPY_CLIENT_SECRET, | |
| redirect_uri=SPOTIPY_REDIRECT_URI, | |
| scope=SCOPE, | |
| cache_path=".cache-spotify" | |
| ) | |
| sp = spotipy.Spotify(auth_manager=auth_manager) | |
| # === LCD setup === | |
| lcd = LcdCommRevA(com_port=COM_PORT, display_width=DISPLAY_HEIGHT, display_height=DISPLAY_WIDTH) | |
| lcd.Reset() | |
| lcd.InitializeComm() | |
| lcd.SetBrightness(level=100) | |
| lcd.SetOrientation(Orientation.LANDSCAPE) | |
| album_cache = {"track_id": None, "album_bytes": None} | |
| # === Fonts === | |
| ROBOTO_FOLDER = os.path.join(SCRIPT_DIR, "res", "fonts", "roboto") | |
| def find_roboto_file(preferred): | |
| for n in preferred: | |
| p = os.path.join(ROBOTO_FOLDER, n) | |
| if os.path.exists(p): return p | |
| return None | |
| ROBOTO_BLACK_PATH = find_roboto_file(["Roboto-Bold.ttf", "roboto-bold.ttf"]) or None | |
| ROBOTO_REG_PATH = find_roboto_file(["Roboto-Regular.ttf", "roboto.ttf"]) or ROBOTO_BLACK_PATH | |
| def load_font(path, size): | |
| return ImageFont.truetype(path, size) if path and os.path.exists(path) else ImageFont.load_default() | |
| _font_cache = {} | |
| def get_font(size, bold=False): | |
| key = (bold, size) | |
| if key not in _font_cache: | |
| _font_cache[key] = load_font(ROBOTO_BLACK_PATH if bold else ROBOTO_REG_PATH, size) | |
| return _font_cache[key] | |
| # === Shutdown === | |
| def shutdown_screen(): | |
| try: | |
| lcd.SetBrightness(0) | |
| lcd.ClearScreen() | |
| except: pass | |
| atexit.register(shutdown_screen) | |
| # === Power events === | |
| def power_event_handler(hwnd, msg, wparam, lparam): | |
| if msg == win32con.WM_POWERBROADCAST: | |
| if wparam == win32con.PBT_APMSUSPEND: | |
| shutdown_screen() | |
| elif wparam == win32con.PBT_APMRESUMESUSPEND: | |
| lcd.SetBrightness(100) | |
| return True | |
| def start_message_loop(): | |
| hinst = win32gui.GetModuleHandle(None) | |
| wc = win32gui.WNDCLASS() | |
| wc.lpfnWndProc = power_event_handler | |
| wc.lpszClassName = "SpotiScreenPowerHandler" | |
| class_atom = win32gui.RegisterClass(wc) | |
| win32gui.CreateWindow(class_atom, "SpotiScreenPowerHandler", 0, 0, 0, 0, 0, 0, 0, hinst, None) | |
| win32gui.PumpMessages() | |
| threading.Thread(target=start_message_loop, daemon=True).start() | |
| # === Helpers === | |
| def fit_text(draw, text, font, max_width): | |
| if draw.textlength(text, font=font) <= max_width: return text | |
| for cut in range(len(text)-1, 0, -1): | |
| cand = text[:cut] + "…" | |
| if draw.textlength(cand, font=font) <= max_width: return cand | |
| return "…" | |
| def render_rounded_album(album_bytes, size=200, radius=16): | |
| try: | |
| album = Image.open(io.BytesIO(album_bytes)).convert("RGB") | |
| album = album.resize((size, size), Image.LANCZOS) | |
| mask = Image.new("L", (size, size), 0) | |
| md = ImageDraw.Draw(mask) | |
| md.rounded_rectangle([0,0,size,size], radius, fill=255) | |
| album.putalpha(mask) | |
| bg = Image.new("RGB", (size, size), (0,0,0)) | |
| bg.paste(album, (0,0), mask=album) | |
| return bg | |
| except: return None | |
| def draw_sleek_controls(draw, center_x, center_y, is_playing, spacing=70, size=28): | |
| # previous << | |
| prev_x = center_x - spacing | |
| draw.polygon([(prev_x+12, center_y-15),(prev_x+12, center_y+15),(prev_x-5, center_y)], fill=(200,200,200)) | |
| draw.polygon([(prev_x-5, center_y-15),(prev_x-5, center_y+15),(prev_x-22, center_y)], fill=(200,200,200)) | |
| # next >> | |
| next_x = center_x + spacing | |
| draw.polygon([(next_x-22, center_y-15),(next_x-22, center_y+15),(next_x-5, center_y)], fill=(200,200,200)) | |
| draw.polygon([(next_x-5, center_y-15),(next_x-5, center_y+15),(next_x+12, center_y)], fill=(200,200,200)) | |
| # play/pause | |
| if is_playing: | |
| draw.rectangle([center_x-12, center_y-15, center_x-4, center_y+15], fill=(200,200,200)) | |
| draw.rectangle([center_x+4, center_y-15, center_x+12, center_y+15], fill=(200,200,200)) | |
| else: | |
| draw.polygon([(center_x-12, center_y-15),(center_x-12, center_y+15),(center_x+15, center_y)], fill=(200,200,200)) | |
| # === Rendering === | |
| def render_now_playing(track, is_playing, progress_ms, duration_ms, album_bytes, out_path): | |
| img = Image.new("RGB", (DISPLAY_WIDTH, DISPLAY_HEIGHT), (0,0,0)) | |
| draw = ImageDraw.Draw(img) | |
| font_title = get_font(24, bold=True) | |
| font_artist = get_font(20) | |
| # Left: album | |
| album_img = render_rounded_album(album_bytes, size=180, radius=10) | |
| if album_img: | |
| img.paste(album_img, (20, (DISPLAY_HEIGHT-album_img.height)//2)) | |
| # Right: text + bar + controls | |
| info_x = 220 | |
| title = fit_text(draw, track.get("name","Unknown"), font_title, DISPLAY_WIDTH-info_x-20) | |
| artists = fit_text(draw, ", ".join([a["name"] for a in track.get("artists",[])]), font_artist, DISPLAY_WIDTH-info_x-20) | |
| draw.text((info_x, 60), title, font=font_title, fill=(255,255,255)) | |
| draw.text((info_x, 100), artists, font=font_artist, fill=(200,200,200)) | |
| # progress bar closer to text | |
| bar_w, bar_h = DISPLAY_WIDTH-info_x-40, 10 | |
| bar_x, bar_y = info_x, 160 | |
| draw.rounded_rectangle([bar_x, bar_y, bar_x+bar_w, bar_y+bar_h], radius=5, fill=(60,60,60)) | |
| pct = (progress_ms/duration_ms) if duration_ms else 0 | |
| pct = max(0,min(1,pct)) | |
| draw.rounded_rectangle([bar_x, bar_y, bar_x+int(bar_w*pct), bar_y+bar_h], radius=5, fill=(200,200,200)) | |
| # controls just under bar | |
| ctrl_y = 220 | |
| center_x = info_x + bar_w//2 | |
| draw_sleek_controls(draw, center_x, ctrl_y, is_playing) | |
| img.save(out_path) | |
| return out_path | |
| # === Main loop === | |
| while True: | |
| try: | |
| data = sp.current_user_playing_track() | |
| if not data or not data.get("item"): | |
| time.sleep(POLL_INTERVAL) | |
| continue | |
| item=data["item"] | |
| progress_ms=data.get("progress_ms",0) | |
| duration_ms=item.get("duration_ms",0) | |
| is_playing=data.get("is_playing",True) | |
| if album_cache["track_id"]!=item["id"]: | |
| album_cache["track_id"]=item["id"] | |
| album_cache["album_bytes"]=None | |
| images=item.get("album",{}).get("images",[]) | |
| if images: | |
| try: album_cache["album_bytes"]=requests.get(images[0]["url"],timeout=5).content | |
| except: pass | |
| tmp=os.path.join(tempfile.gettempdir(), f"turing_{uuid.uuid4().hex}.png") | |
| render_now_playing(item,is_playing,progress_ms,duration_ms,album_cache["album_bytes"],tmp) | |
| try:lcd.DisplayBitmap(tmp) | |
| finally: os.remove(tmp) | |
| time.sleep(POLL_INTERVAL) | |
| except Exception as e: | |
| print("Main loop exception:", e) | |
| time.sleep(POLL_INTERVAL) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment