Skip to content

Instantly share code, notes, and snippets.

@Joabutt
Created September 27, 2025 13:55
Show Gist options
  • Select an option

  • Save Joabutt/07037f1ac2a779a4b7f9e02bbeab78d6 to your computer and use it in GitHub Desktop.

Select an option

Save Joabutt/07037f1ac2a779a4b7f9e02bbeab78d6 to your computer and use it in GitHub Desktop.
Spotify Playing for UsbPCMonitor 3.5" [Landscape version]
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